@cosmoops/connect-sdk 0.3.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 (81) 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 +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/esm/cli/doctor.js +3 -3
  33. package/dist/esm/cli/init.js +3 -3
  34. package/dist/esm/client/components/access-gate.js +158 -0
  35. package/dist/esm/client/components/account-sessions.js +21 -19
  36. package/dist/esm/client/components/app-shell.js +154 -0
  37. package/dist/esm/client/components/apps-grid.js +2 -1
  38. package/dist/esm/client/components/integrations-modal.js +92 -0
  39. package/dist/esm/client/components/motion.js +21 -0
  40. package/dist/esm/client/components/nav.js +89 -0
  41. package/dist/esm/client/components/plans.js +39 -37
  42. package/dist/esm/client/components/readiness.js +3 -1
  43. package/dist/esm/client/components/side-nav.js +53 -0
  44. package/dist/esm/client/components/subscription.js +2 -1
  45. package/dist/esm/client/components/surfaces.js +51 -0
  46. package/dist/esm/client/components/usage-meter.js +3 -2
  47. package/dist/esm/client/components/user-menu.js +31 -4
  48. package/dist/esm/client/index.js +15 -1
  49. package/dist/esm/client/provider.js +53 -2
  50. package/dist/esm/client/use-active-path.js +74 -0
  51. package/dist/esm/errors.js +1 -1
  52. package/dist/esm/next/index.js +95 -15
  53. package/dist/esm/server/client.js +10 -2
  54. package/dist/esm/server/handler.js +89 -6
  55. package/dist/esm/server/index.js +5 -0
  56. package/dist/esm/server/resources/analytics.js +62 -0
  57. package/dist/esm/server/resources/identity.js +1 -1
  58. package/dist/esm/server/resources/integrations.js +62 -0
  59. package/dist/esm/server/resources/sessions.js +1 -1
  60. package/dist/esm/server/webhooks.js +123 -0
  61. package/dist/theme.css +28 -0
  62. package/dist/types/client/components/access-gate.d.ts +79 -0
  63. package/dist/types/client/components/app-shell.d.ts +115 -0
  64. package/dist/types/client/components/integrations-modal.d.ts +21 -0
  65. package/dist/types/client/components/motion.d.ts +22 -0
  66. package/dist/types/client/components/nav.d.ts +110 -0
  67. package/dist/types/client/components/side-nav.d.ts +31 -0
  68. package/dist/types/client/components/surfaces.d.ts +51 -0
  69. package/dist/types/client/index.d.ts +12 -1
  70. package/dist/types/client/provider.d.ts +38 -3
  71. package/dist/types/client/use-active-path.d.ts +8 -0
  72. package/dist/types/errors.d.ts +1 -1
  73. package/dist/types/next/index.d.ts +40 -0
  74. package/dist/types/server/client.d.ts +8 -2
  75. package/dist/types/server/index.d.ts +7 -0
  76. package/dist/types/server/resources/analytics.d.ts +31 -0
  77. package/dist/types/server/resources/identity.d.ts +12 -2
  78. package/dist/types/server/resources/integrations.d.ts +40 -0
  79. package/dist/types/server/webhooks.d.ts +67 -0
  80. package/dist/types/types.d.ts +182 -1
  81. package/package.json +10 -4
@@ -11,7 +11,7 @@
11
11
  * whatever theme the host service platform already configured, and one theme
12
12
  * change lands identically in all of them.
13
13
  */
14
- export { ConnectProvider, useConnect, useConnectUser, useConnectEntitlements, useConnectPlans, useConnectApps, useConnectAddonUsage, useConnectSessions, useConnectResource, CONNECT_APP_URL, } from "./provider.js";
14
+ export { ConnectProvider, useConnect, useConnectUser, useConnectEntitlements, useConnectPlans, useConnectApps, useConnectAddonUsage, useConnectSessions, useConnectIntegrations, useConnectResource, CONNECT_APP_URL, } from "./provider.js";
15
15
  // Re-exported from the root entry rather than kept there alone: it is a pure
16
16
  // helper, but every caller is UI code, and making a component file reach two
17
17
  // entry points for one function is friction with nothing behind it.
@@ -24,7 +24,20 @@ export { ConnectUserMenu, ConnectManageSubscriptionButton, ConnectInvoicesButton
24
24
  // Built, compiled into `dist/`, and reachable from no entry point until now -
25
25
  // `ConnectUserMenu` was the only thing that could render it.
26
26
  export { ConnectAccountSessions } from "./components/account-sessions.js";
27
+ // The frame the rest of these components live inside. Exported next to the
28
+ // pieces it composes rather than in a section of its own - an integration
29
+ // reaching for a sidebar wants to find the launcher and the account menu in
30
+ // the same breath, since the shell already mounts both.
31
+ export { ConnectAppShell } from "./components/app-shell.js";
32
+ export { ConnectSideNav } from "./components/side-nav.js";
33
+ export { useActivePath } from "./use-active-path.js";
34
+ // The nav model is plain data and pure functions - a host generating its nav
35
+ // from its own route table needs the types, and `resolveActivePath` is the
36
+ // same longest-prefix rule the sidebar highlights with, so a breadcrumb or a
37
+ // page title derived from it cannot disagree with the rail.
38
+ export { normalizeNav, flattenNav, normalizePath, resolveActivePath, isNavItemActive, gateExemptPaths, } from "./components/nav.js";
27
39
  export { ConnectAppsGrid } from "./components/apps-grid.js";
40
+ export { ConnectIntegrationsModal } from "./components/integrations-modal.js";
28
41
  export { ConnectPlans } from "./components/plans.js";
29
42
  export { ConnectSubscribeDialog } from "./components/subscribe-dialog.js";
30
43
  // The subscribe / upgrade / downgrade flow's two moving parts, exported so a
@@ -34,6 +47,7 @@ export { ConnectSubscribeDialog } from "./components/subscribe-dialog.js";
34
47
  // Subscription state, gates and the surfaces around the plan grid.
35
48
  export { ConnectSubscriptionGate, ConnectNoSubscriptionCard, ConnectSubscriptionCard, ConnectTrialBanner, ConnectSeatUsage, resolveSubscriptionState, } from "./components/subscription.js";
36
49
  export { ConnectReadinessBanner, ConnectReadinessGate } from "./components/readiness.js";
50
+ export { ConnectAccessGate, useConnectAccessGate } from "./components/access-gate.js";
37
51
  export { ConnectFeatureGate, useConnectFeature } from "./components/feature-gate.js";
38
52
  export { ConnectUsageMeter } from "./components/usage-meter.js";
39
53
  export { ConnectPlansPage } from "./components/plans-page.js";
@@ -3,6 +3,8 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
4
4
  export const CONNECT_APP_URL = "https://connect.cosmoops.com";
5
5
  const ConnectContext = createContext(null);
6
+ /** Marks that this tab has already spent its one silent attempt - see `ConnectProviderProps.silentSignIn`. */
7
+ const SILENT_ATTEMPT_KEY = "connect_silent_attempted";
6
8
  /**
7
9
  * Wraps the part of your app that shows Connect UI.
8
10
  *
@@ -17,14 +19,44 @@ const ConnectContext = createContext(null);
17
19
  * </ConnectProvider>
18
20
  * ```
19
21
  */
20
- export function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri, appUrl = CONNECT_APP_URL, fetch: fetchImpl, children, }) {
22
+ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redirectUri, appUrl = CONNECT_APP_URL, fetch: fetchImpl, silentSignIn = false, children, }) {
21
23
  // One state cell, not three. `isLoading` is *derived* from "no result yet"
22
24
  // rather than set - a `setIsLoading(true)` at the top of an effect is a
23
25
  // synchronous setState inside that effect, which cascades an extra render
24
26
  // and is what `react-hooks/set-state-in-effect` flags. Everything below
25
27
  // writes state only from an async callback.
26
28
  const [result, setResult] = useState(null);
27
- const isLoading = result === null;
29
+ /**
30
+ * Whether this tab has already spent its one silent attempt, sampled once
31
+ * at mount.
32
+ *
33
+ * A lazy `useState` initializer rather than a read inside the render body:
34
+ * `sessionStorage` is external mutable state, and reading it during render
35
+ * is impure - with the React Compiler on, that is exactly the kind of thing
36
+ * that can be hoisted or memoized into the wrong answer. An initializer
37
+ * runs once, which is all this needs; the value only ever changes as this
38
+ * component navigates the page away.
39
+ *
40
+ * Safe on the server, where it reads `false`: `result` is `null` during SSR
41
+ * and first client render alike, so `isLoading` below is `true` on both
42
+ * regardless of this value, and hydration cannot diverge on it.
43
+ */
44
+ const [silentAttemptSpent] = useState(() => typeof window !== "undefined" && window.sessionStorage.getItem(SILENT_ATTEMPT_KEY) === "1");
45
+ /**
46
+ * A silent attempt is settled-signed-out *and* about to redirect. Reported
47
+ * as still-loading, which is the honest answer: the question "is this
48
+ * visitor signed in" has not been finally answered yet, and there is a
49
+ * navigation already on its way.
50
+ *
51
+ * Without this the provider publishes `signed out` for the one frame before
52
+ * the effect below fires, so every consumer paints its signed-out state -
53
+ * a "Sign in" button, the plan-picker gate - and then the page vanishes
54
+ * underneath it. Flashing a sales pitch at someone who is about to be
55
+ * silently signed in is the same bug `ConnectSubscriptionGate` exists to
56
+ * prevent, one layer down.
57
+ */
58
+ const silentPending = silentSignIn && !silentAttemptSpent && result !== null && !result.session.user && !result.error;
59
+ const isLoading = result === null || silentPending;
28
60
  const session = result?.session ?? null;
29
61
  const error = result?.error ?? null;
30
62
  // One entry per path. Held in a ref, not state, because a cache write must
@@ -104,6 +136,21 @@ export function ConnectProvider({ clientId = "", basePath = "/api/connect", redi
104
136
  // eslint-disable-next-line react-hooks/set-state-in-effect
105
137
  void refresh();
106
138
  }, [refresh]);
139
+ useEffect(() => {
140
+ // `silentPending` already encodes settled-and-signed-out, no prior
141
+ // attempt, and no bridge error - a failed bridge read says nothing about
142
+ // whether Connect has a session, and bouncing the browser on a transient
143
+ // network failure would turn a recoverable error into a navigation nobody
144
+ // asked for.
145
+ if (!silentPending || typeof window === "undefined")
146
+ return;
147
+ // Written before navigating, not after: a navigation that begins before
148
+ // the marker lands leaves the tab unmarked and free to probe again on the
149
+ // way back, which is the loop this bounds.
150
+ window.sessionStorage.setItem(SILENT_ATTEMPT_KEY, "1");
151
+ const next = `${window.location.pathname}${window.location.search}`;
152
+ window.location.href = `${basePath}/signin?prompt=none&next=${encodeURIComponent(next)}`;
153
+ }, [silentPending, basePath]);
107
154
  const signIn = useCallback((options = {}) => {
108
155
  const url = new URL("/sso/authorize", appUrl);
109
156
  url.searchParams.set("client_id", clientId);
@@ -186,6 +233,10 @@ export function useConnectPlans() {
186
233
  export function useConnectApps() {
187
234
  return useConnectResource("/apps");
188
235
  }
236
+ /** Enabled Composio-hosted toolkits and this organization's connection status for each. */
237
+ export function useConnectIntegrations() {
238
+ return useConnectResource("/integrations");
239
+ }
189
240
  /**
190
241
  * This Connect account's own login sessions - account-wide, not scoped to
191
242
  * this service, see `SessionsListResponse`'s doc comment. The component
@@ -0,0 +1,74 @@
1
+ "use client";
2
+ import { useSyncExternalStore } from "react";
3
+ const LOCATION_CHANGE = "connect:locationchange";
4
+ let patched = false;
5
+ /**
6
+ * Make `history.pushState`/`replaceState` announce themselves.
7
+ *
8
+ * Neither fires an event - `popstate` covers Back and Forward only - so a
9
+ * client-side router that pushes a new URL moves the address bar and tells
10
+ * nothing outside itself. Without this, a sidebar reading `location.pathname`
11
+ * highlights whatever route the page first loaded on and then never moves
12
+ * again.
13
+ *
14
+ * Both wrappers call through to the original first and return its result, so
15
+ * the patch is transparent to whatever router installed the navigation; it is
16
+ * applied at most once per document, and only when a `ConnectSideNav` is
17
+ * actually mounted without an `activePath`. Pass `activePath` (Next:
18
+ * `usePathname()`) and none of this runs at all - which is the recommended
19
+ * path for any app that has a router to ask.
20
+ */
21
+ function patchHistory() {
22
+ if (patched || typeof window === "undefined" || typeof window.history === "undefined")
23
+ return;
24
+ patched = true;
25
+ for (const method of ["pushState", "replaceState"]) {
26
+ const original = window.history[method];
27
+ window.history[method] = function patchedHistoryMethod(...args) {
28
+ const result = original.apply(this, args);
29
+ window.dispatchEvent(new Event(LOCATION_CHANGE));
30
+ return result;
31
+ };
32
+ }
33
+ }
34
+ function subscribe(onStoreChange) {
35
+ patchHistory();
36
+ window.addEventListener("popstate", onStoreChange);
37
+ window.addEventListener(LOCATION_CHANGE, onStoreChange);
38
+ return () => {
39
+ window.removeEventListener("popstate", onStoreChange);
40
+ window.removeEventListener(LOCATION_CHANGE, onStoreChange);
41
+ };
42
+ }
43
+ function getSnapshot() {
44
+ return window.location.pathname;
45
+ }
46
+ /**
47
+ * The server never has a location, and guessing one is worse than having
48
+ * none: a guess that misses renders a *different* item highlighted on the
49
+ * server than on the client, which is a hydration mismatch. `""` matches no
50
+ * nav item, so the first paint has no highlight and the client's first commit
51
+ * adds it.
52
+ *
53
+ * Doubles as the snapshot when `activePath` *was* supplied - the value is
54
+ * discarded either way, and pairing it with `noopSubscribe` is what keeps a
55
+ * controlled shell from ever touching `history` or `window.location`.
56
+ */
57
+ function emptySnapshot() {
58
+ return "";
59
+ }
60
+ function noopSubscribe() {
61
+ return () => { };
62
+ }
63
+ /**
64
+ * The current path, from `activePath` when given and from the browser
65
+ * otherwise.
66
+ *
67
+ * Exported because a host building its own shell out of `ConnectSideNav`
68
+ * needs the same fallback the shell uses.
69
+ */
70
+ export function useActivePath(activePath) {
71
+ const controlled = activePath !== undefined;
72
+ const live = useSyncExternalStore(controlled ? noopSubscribe : subscribe, controlled ? emptySnapshot : getSnapshot, emptySnapshot);
73
+ return activePath ?? live;
74
+ }
@@ -55,7 +55,7 @@ export class ConnectError extends Error {
55
55
  * guessing keys. These two are the exception: reaching them required
56
56
  * presenting a key whose hash matched, so you already hold it, and what you
57
57
  * need to know is that no retry and no scope change will fix this - the key
58
- * has to be reissued from the service's Setup tab.
58
+ * has to be reissued from the service's Integration tab.
59
59
  *
60
60
  * Worth alerting on. It is the one failure that will not resolve itself and
61
61
  * takes down every call your integration makes.
@@ -1,13 +1,18 @@
1
+ import { cache } from "react";
1
2
  import { ConnectClient } from "../server/client.js";
2
3
  import { createConnectHandler } from "../server/handler.js";
3
4
  /**
4
5
  * Plug-and-play wiring for Next.js.
5
6
  *
6
7
  * Everything below is server-side. Nothing here is *statically* imported from
7
- * `next`, so this module loads in App Router route handlers, the Node runtime
8
- * and the edge runtime alike - and needs no peer dependency. The one place
9
- * that does reach for `next/headers` does it behind a dynamic import, inside
10
- * the function that needs it, and says so when it isn't there.
8
+ * `next` itself, so this module loads in App Router route handlers, the Node
9
+ * runtime and the edge runtime alike without a `next` peer dependency. The
10
+ * one place that does reach for `next/headers` (or, in `requireConnectSession`,
11
+ * `next/navigation`) does it behind a dynamic import, inside the function that
12
+ * needs it, and says so when it isn't there. `react` is a real, static
13
+ * dependency though - `getConnectSession`'s automatic per-request memoization
14
+ * (below) is built on its `cache()`, which every App Router project already
15
+ * has.
11
16
  *
12
17
  * The whole integration is two files:
13
18
  *
@@ -33,7 +38,7 @@ import { createConnectHandler } from "../server/handler.js";
33
38
  * | `CONNECT_SESSION_COOKIE` | no | session cookie name |
34
39
  *
35
40
  * Register `{your origin}/api/connect/callback` as a redirect URI in the
36
- * service's Setup tab. That is the only step this cannot do for you.
41
+ * service's Integration tab. That is the only step this cannot do for you.
37
42
  *
38
43
  * No `next.config.ts` changes needed either, including `transpilePackages`:
39
44
  * `dist/` is already ES2022 with `"use client"` hoisted to the top of every
@@ -198,6 +203,35 @@ async function requestFromHeaders() {
198
203
  const proto = incoming.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
199
204
  return new Request(`${proto}://${host}/`, { headers: incoming });
200
205
  }
206
+ async function readSession(request, options) {
207
+ const store = options.session ?? cookieSessionStore();
208
+ const token = await store.read(request);
209
+ if (!token)
210
+ return null;
211
+ try {
212
+ return await (options.client ?? connectClient()).identity.getUser(token);
213
+ }
214
+ catch {
215
+ return null;
216
+ }
217
+ }
218
+ /**
219
+ * The ambient case - no explicit `request`, default client, default cookie
220
+ * store - wrapped in React's `cache()` so every call in the same request
221
+ * shares one round trip to Connect. A layout reading the session for a nav
222
+ * avatar and the page beneath it reading it again for a role check hit this
223
+ * exact function; without the wrapper that's two live reads of the same
224
+ * answer.
225
+ *
226
+ * Deliberately its own zero-argument function rather than `cache()` wrapping
227
+ * `getConnectSession` directly - `cache()` keys on argument identity, and
228
+ * `getConnectSession`'s default `options = {}` is a fresh object on every
229
+ * call, so wrapping it as-is would never hit the cache for the very call
230
+ * pattern this exists to speed up.
231
+ */
232
+ const cachedAmbientSession = cache(async () => {
233
+ return readSession(await requestFromHeaders(), {});
234
+ });
201
235
  /**
202
236
  * The signed-in user for a Server Component, Route Handler or Server Action,
203
237
  * or `null`.
@@ -208,6 +242,9 @@ async function requestFromHeaders() {
208
242
  * if (!session) redirect("/api/connect/signin?next=/dashboard");
209
243
  * ```
210
244
  *
245
+ * `requireConnectSession()` below is that exact check in one call, for the
246
+ * common case of "redirect if signed out."
247
+ *
211
248
  * Pass a `Request` to use it somewhere `next/headers` does not exist - a
212
249
  * framework-agnostic handler, a test, a custom server. Both forms take the
213
250
  * same path afterwards; the argument is only how the cookie is found.
@@ -219,18 +256,61 @@ async function requestFromHeaders() {
219
256
  *
220
257
  * Returns `null` for every failure - an expired session and a missing one both
221
258
  * mean "not signed in", and a page guard should not need a try/catch.
259
+ *
260
+ * **Memoized per request for the plain, zero-argument call** - the shape
261
+ * every page, layout and Server Action actually uses - via React's `cache()`.
262
+ * Passing an explicit `request`, `client` or `session` bypasses the cache
263
+ * and always makes a live call: those are for tests and non-ambient
264
+ * callers, where a fresh read every time is the point, not an oversight.
222
265
  */
223
266
  export async function getConnectSession(request, options = {}) {
224
- const store = options.session ?? cookieSessionStore();
225
- const token = await store.read(request ?? (await requestFromHeaders()));
226
- if (!token)
227
- return null;
228
- try {
229
- return await (options.client ?? connectClient()).identity.getUser(token);
230
- }
231
- catch {
232
- return null;
233
- }
267
+ if (!request && !options.client && !options.session)
268
+ return cachedAmbientSession();
269
+ return readSession(request ?? (await requestFromHeaders()), options);
270
+ }
271
+ /**
272
+ * `getConnectSession()`, minus the boilerplate of checking it and redirecting
273
+ * yourself:
274
+ *
275
+ * ```tsx
276
+ * // app/dashboard/page.tsx
277
+ * const session = await requireConnectSession();
278
+ * ```
279
+ *
280
+ * Redirects to `/signin` under the same `basePath` as `getConnectSession`'s
281
+ * default (`/api/connect`) when signed out - pass `signInPath` if you mounted
282
+ * the bridge somewhere else, and `next` to send the visitor back to where
283
+ * they were once they're signed in. Only sensible in a Server Component,
284
+ * Route Handler or Server Action, the same three places `redirect()` from
285
+ * `next/navigation` itself works.
286
+ *
287
+ * A thin wrapper, not a framework of its own - reach for `getConnectSession`
288
+ * plus your own check when "not signed in" isn't "redirect to sign-in"
289
+ * (an API route returning 401, say). Role and entitlement checks stay
290
+ * outside this function on purpose: `session.serviceRole.granted` and
291
+ * `connect.entitlements.*` are one line each on top of the session this
292
+ * already gives you, and where that line redirects (a plans page, a
293
+ * "forbidden" screen) is a decision only your app can make - see
294
+ * "Authentication & authorization" for the pattern.
295
+ */
296
+ export async function requireConnectSession(request, options = {}) {
297
+ const session = await getConnectSession(request, options);
298
+ if (session)
299
+ return session;
300
+ const signIn = options.signInPath ?? "/api/connect/signin";
301
+ return redirectTo(options.next ? `${signIn}?next=${encodeURIComponent(options.next)}` : signIn);
302
+ }
303
+ /**
304
+ * `next/navigation`'s `redirect()`, imported the same dynamic way as
305
+ * `next/headers` above and for the same reason: no static dependency on
306
+ * `next`'s own types at this package's build time. Typed by hand rather than
307
+ * imported, since that's the only way to erase them - `redirect()` itself is
308
+ * typed `never`, which is what lets `requireConnectSession` return here
309
+ * without its own `return` after the call.
310
+ */
311
+ async function redirectTo(url) {
312
+ const { redirect } = (await import("next/navigation"));
313
+ return redirect(url);
234
314
  }
235
315
  export { createConnectHandler } from "../server/handler.js";
236
316
  export { ConnectClient } from "../server/client.js";
@@ -5,7 +5,9 @@ import { BillingResource } from "./resources/billing.js";
5
5
  import { DriveResource } from "./resources/drive.js";
6
6
  import { EntitlementsResource } from "./resources/entitlements.js";
7
7
  import { IdentityResource } from "./resources/identity.js";
8
+ import { IntegrationsResource } from "./resources/integrations.js";
8
9
  import { LogsResource } from "./resources/logs.js";
10
+ import { AnalyticsResource } from "./resources/analytics.js";
9
11
  import { OtpResource } from "./resources/otp.js";
10
12
  import { SessionsResource } from "./resources/sessions.js";
11
13
  export const DEFAULT_BASE_URL = "https://connect.cosmoops.com";
@@ -76,12 +78,16 @@ export class ConnectClient {
76
78
  otp;
77
79
  /** Feature events into the Console's audit log, filterable to your service. */
78
80
  logs;
81
+ /** Emit product events and read back this service's aggregated numbers. */
82
+ analytics;
79
83
  /** This Connect account's own login sessions - account-wide, not scoped to this service. Requires the `sessions:manage` scope. */
80
84
  sessions;
85
+ /** Composio-hosted third-party connections (Google Calendar, Slack, ...) an organization has connected. */
86
+ integrations;
81
87
  constructor(options) {
82
88
  assertNotBrowser();
83
89
  if (!options.apiKey) {
84
- throw new TypeError("ConnectClient requires `apiKey` - the plaintext shown once in the Setup tab.");
90
+ throw new TypeError("ConnectClient requires `apiKey` - the plaintext shown once in the Integration tab.");
85
91
  }
86
92
  const appBaseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
87
93
  const transport = {
@@ -103,8 +109,10 @@ export class ConnectClient {
103
109
  this.ai = new AiResource(transport);
104
110
  this.otp = new OtpResource(transport);
105
111
  this.logs = new LogsResource(transport);
112
+ this.analytics = new AnalyticsResource(transport);
106
113
  this.sessions = new SessionsResource(transport, options.clientId ?? null);
114
+ this.integrations = new IntegrationsResource(transport);
107
115
  }
108
116
  }
109
117
  /** Kept in step with `package.json` by `sdk.test.ts`. */
110
- export const SDK_VERSION = "0.3.0";
118
+ export const SDK_VERSION = "0.6.0";
@@ -101,6 +101,7 @@ export function createConnectHandler(options) {
101
101
  const { client, session } = options;
102
102
  const defaultRedirect = options.defaultRedirect ?? "/";
103
103
  const stateCookieName = options.stateCookieName ?? "connect_oauth_state";
104
+ const silentCookieName = `${stateCookieName}_silent`;
104
105
  const callbackPath = `${basePath}/callback`;
105
106
  /**
106
107
  * Short-lived, httpOnly, and `SameSite=Lax` - `Strict` would not be sent on
@@ -121,6 +122,32 @@ export function createConnectHandler(options) {
121
122
  .filter(Boolean)
122
123
  .join("; ");
123
124
  }
125
+ /**
126
+ * Marks the in-flight round trip as a silent (`prompt=none`) one, so the
127
+ * callback can tell a *probe that found nobody* from a *sign-in that
128
+ * failed*. The two arrive as the identical `error=login_required`, and they
129
+ * need opposite handling - see the callback.
130
+ *
131
+ * A cookie of its own rather than a flag folded into the state cookie:
132
+ * that one is parsed by splitting on `:` with the destination URL rejoined
133
+ * from the remainder, so smuggling a third field through it is exactly the
134
+ * kind of encoding trick that breaks the first time a `next` URL contains
135
+ * something unexpected. Same attributes and lifetime as the state cookie
136
+ * it travels beside.
137
+ */
138
+ function silentCookie(value, secure) {
139
+ const cleared = value === "";
140
+ return [
141
+ `${silentCookieName}=${encodeURIComponent(value)}`,
142
+ `Path=${basePath}`,
143
+ "HttpOnly",
144
+ "SameSite=Lax",
145
+ secure ? "Secure" : "",
146
+ cleared ? "Max-Age=0" : "Max-Age=900",
147
+ ]
148
+ .filter(Boolean)
149
+ .join("; ");
150
+ }
124
151
  async function requireToken(request) {
125
152
  const token = await session.read(request);
126
153
  if (!token) {
@@ -166,6 +193,37 @@ export function createConnectHandler(options) {
166
193
  const { user } = await whoami(request);
167
194
  return json({ apps: await client.apps.list(user.organizationId) });
168
195
  },
196
+ "GET /integrations": async (request) => {
197
+ const { user } = await whoami(request);
198
+ return json({ toolkits: await client.integrations.list(user.organizationId) });
199
+ },
200
+ "POST /integrations/connect": async (request) => {
201
+ const { user } = await whoami(request);
202
+ const body = (await request.json().catch(() => null));
203
+ if (!body?.toolkitSlug || !body?.returnUrl) {
204
+ throw new ConnectError({
205
+ status: 400,
206
+ code: "validation_error",
207
+ message: "toolkitSlug and returnUrl are required",
208
+ endpoint: "POST /integrations/connect",
209
+ });
210
+ }
211
+ return json(await client.integrations.initiateConnect(user.organizationId, body.toolkitSlug, body.returnUrl));
212
+ },
213
+ "POST /integrations/disconnect": async (request) => {
214
+ const { user } = await whoami(request);
215
+ const body = (await request.json().catch(() => null));
216
+ if (!body?.toolkitSlug) {
217
+ throw new ConnectError({
218
+ status: 400,
219
+ code: "validation_error",
220
+ message: "toolkitSlug is required",
221
+ endpoint: "POST /integrations/disconnect",
222
+ });
223
+ }
224
+ await client.integrations.disconnect(user.organizationId, body.toolkitSlug);
225
+ return json({ ok: true });
226
+ },
169
227
  // Account-wide, not scoped to this service - see `SessionsResource`'s
170
228
  // doc comment. `requireToken` alone identifies the caller; the target
171
229
  // `sessionId` on the revoke routes travels in the JSON body, not the
@@ -205,14 +263,19 @@ export function createConnectHandler(options) {
205
263
  const secure = url.protocol === "https:";
206
264
  const state = createState();
207
265
  const next = safeRedirect(url.searchParams.get("next"), defaultRedirect);
266
+ const requestedPrompt = url.searchParams.get("prompt");
267
+ const prompt = requestedPrompt === "create" || requestedPrompt === "none" ? requestedPrompt : undefined;
208
268
  const authorizeUrl = client.identity.authorizeUrl({
209
269
  redirectUri: `${url.origin}${callbackPath}`,
210
270
  state,
211
- ...(url.searchParams.get("prompt") === "create" ? { prompt: "create" } : {}),
271
+ ...(prompt ? { prompt } : {}),
212
272
  });
213
273
  // State and destination travel together in one cookie so the callback
214
274
  // cannot end up with one and not the other.
215
- return redirect(authorizeUrl, [stateCookie(`${state}:${next}`, secure)]);
275
+ const cookies = [stateCookie(`${state}:${next}`, secure)];
276
+ if (prompt === "none")
277
+ cookies.push(silentCookie("1", secure));
278
+ return redirect(authorizeUrl, cookies);
216
279
  },
217
280
  /**
218
281
  * The other end of the round trip. Verifies `state`, exchanges the code,
@@ -229,10 +292,29 @@ export function createConnectHandler(options) {
229
292
  if (!cookieState || !sameState(cookieState, returnedState)) {
230
293
  // Either a forged callback or a genuinely stale tab. Neither should
231
294
  // mint a session, and neither is worth an error page.
232
- return redirect(`${next}${next.includes("?") ? "&" : "?"}connect_error=state_mismatch`, [clearState]);
295
+ return redirect(`${next}${next.includes("?") ? "&" : "?"}connect_error=state_mismatch`, [
296
+ clearState,
297
+ silentCookie("", secure),
298
+ ]);
233
299
  }
300
+ const wasSilent = readCookie(request, silentCookieName) === "1";
301
+ const clearSilent = silentCookie("", secure);
234
302
  const error = url.searchParams.get("error");
235
303
  if (error) {
304
+ // A silent (`prompt=none`) probe that came back empty-handed. All
305
+ // three of its outcomes mean the same thing to the app - "carry on
306
+ // signed out" - so it returns to `next` quietly: no retry, and no
307
+ // `connect_error`, because nothing went wrong.
308
+ //
309
+ // Getting this wrong is not subtle. The retry below fires on exactly
310
+ // the `login_required` a silent probe returns, so without this branch
311
+ // every silent attempt for a signed-out visitor would escalate itself
312
+ // into a full interactive sign-in redirect - turning "quietly check
313
+ // if they're already here" into "force everyone to log in", which is
314
+ // the precise opposite of the feature.
315
+ if (wasSilent) {
316
+ return redirect(next, [clearState, clearSilent]);
317
+ }
236
318
  // `login_required` is normal traffic, not a fault: a new account gets
237
319
  // pulled out to their inbox to verify, and the ~15 minute request
238
320
  // expires before they return. Connect has already signed them in, so
@@ -242,15 +324,16 @@ export function createConnectHandler(options) {
242
324
  const retry = new URL(`${url.origin}${basePath}/signin`);
243
325
  retry.searchParams.set("next", next);
244
326
  retry.searchParams.set("connect_retry", "1");
245
- return redirect(retry.toString(), [clearState]);
327
+ return redirect(retry.toString(), [clearState, clearSilent]);
246
328
  }
247
329
  return redirect(`${next}${next.includes("?") ? "&" : "?"}connect_error=${encodeURIComponent(error)}`, [
248
330
  clearState,
331
+ clearSilent,
249
332
  ]);
250
333
  }
251
334
  const code = url.searchParams.get("code");
252
335
  if (!code) {
253
- return redirect(`${next}${next.includes("?") ? "&" : "?"}connect_error=missing_code`, [clearState]);
336
+ return redirect(`${next}${next.includes("?") ? "&" : "?"}connect_error=missing_code`, [clearState, clearSilent]);
254
337
  }
255
338
  const result = await client.identity.exchangeCode({
256
339
  code,
@@ -259,7 +342,7 @@ export function createConnectHandler(options) {
259
342
  // Verified before the session is stored, not after - an unverifiable
260
343
  // token must never become a signed-in user.
261
344
  await client.identity.verifyIdToken(result.idToken);
262
- const response = redirect(next, [clearState]);
345
+ const response = redirect(next, [clearState, clearSilent]);
263
346
  await session.write(request, response, result.sessionToken);
264
347
  return response;
265
348
  },
@@ -8,3 +8,8 @@
8
8
  export { ConnectClient, DEFAULT_BASE_URL, SDK_VERSION } from "./client.js";
9
9
  export { createConnectHandler } from "./handler.js";
10
10
  export { createState } from "./resources/identity.js";
11
+ /**
12
+ * Webhook verification and a ready-made POST route. Server-only for the
13
+ * obvious reason: the signing secret must never reach a browser bundle.
14
+ */
15
+ export { createConnectWebhookHandler, verifyConnectWebhook, } from "./webhooks.js";
@@ -0,0 +1,62 @@
1
+ import { request } from "../http.js";
2
+ /**
3
+ * Your service's own analytics: emit product events, and read back the daily
4
+ * numbers Connect aggregates from them.
5
+ *
6
+ * There is no way to name a service in either call. The scope is always the one
7
+ * your API key belongs to - that is the endpoint's security model, not an
8
+ * omission.
9
+ */
10
+ export class AnalyticsResource {
11
+ transport;
12
+ constructor(transport) {
13
+ this.transport = transport;
14
+ }
15
+ /**
16
+ * Daily series for the requested metrics, up to 90 days.
17
+ *
18
+ * **Throws on failure**, unlike `emit` below. A read whose result you are
19
+ * about to render has to be able to say "I could not load"; silently
20
+ * returning an empty series would draw a chart of zeroes.
21
+ */
22
+ async series(query) {
23
+ const params = new URLSearchParams({
24
+ metrics: query.metrics.join(","),
25
+ from: query.from,
26
+ to: query.to,
27
+ });
28
+ return request(this.transport, {
29
+ method: "GET",
30
+ path: `/analytics?${params.toString()}`,
31
+ });
32
+ }
33
+ /**
34
+ * Never throws - the same judgement `LogsResource.write` makes, for the same
35
+ * reason: telemetry that takes down the code path it was observing is worse
36
+ * than a lost event. A failure is reported through `delivered`.
37
+ *
38
+ * Up to 50 events per call. Pass `sessionToken` on an event to attribute it
39
+ * to the signed-in user; without one it still lands, just unattributed.
40
+ */
41
+ async emit(events) {
42
+ const normalized = events.map((event) => ({
43
+ ...event,
44
+ occurredAt: event.occurredAt instanceof Date ? event.occurredAt.toISOString() : event.occurredAt,
45
+ }));
46
+ try {
47
+ const result = await request(this.transport, {
48
+ method: "POST",
49
+ path: "/events",
50
+ json: { events: normalized },
51
+ });
52
+ return {
53
+ delivered: true,
54
+ accepted: result?.accepted ?? events.length,
55
+ attributed: result?.attributed ?? 0,
56
+ };
57
+ }
58
+ catch {
59
+ return { delivered: false, accepted: 0, attributed: 0 };
60
+ }
61
+ }
62
+ }
@@ -177,7 +177,7 @@ export class IdentityResource {
177
177
  throw new ConnectError({
178
178
  status: 0,
179
179
  code: "missing_client_id",
180
- message: "identity.* needs `clientId`. Pass it to `new ConnectClient({ clientId, apiKey })` - it is shown in this service's Setup tab once SSO is enabled.",
180
+ message: "identity.* needs `clientId`. Pass it to `new ConnectClient({ clientId, apiKey })` - it is shown in this service's Integration tab, under Connect client.",
181
181
  endpoint: "identity",
182
182
  });
183
183
  }