@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.
- package/CHANGELOG.md +125 -0
- package/README.md +284 -7
- package/dist/cjs/cli/doctor.js +209 -0
- package/dist/cjs/cli/init.js +20 -8
- package/dist/cjs/client/components/access-gate.js +162 -0
- package/dist/cjs/client/components/account-sessions.js +21 -19
- package/dist/cjs/client/components/app-shell.js +157 -0
- package/dist/cjs/client/components/apps-grid.js +2 -1
- package/dist/cjs/client/components/integrations-modal.js +95 -0
- package/dist/cjs/client/components/motion.js +27 -0
- package/dist/cjs/client/components/nav.js +97 -0
- package/dist/cjs/client/components/plans.js +39 -37
- package/dist/cjs/client/components/readiness.js +3 -1
- package/dist/cjs/client/components/side-nav.js +58 -0
- package/dist/cjs/client/components/subscription.js +2 -1
- package/dist/cjs/client/components/surfaces.js +54 -0
- package/dist/cjs/client/components/usage-meter.js +3 -2
- package/dist/cjs/client/components/user-menu.js +30 -3
- package/dist/cjs/client/index.js +29 -1
- package/dist/cjs/client/provider.js +54 -2
- package/dist/cjs/client/use-active-path.js +77 -0
- package/dist/cjs/errors.js +1 -1
- package/dist/cjs/next/index.js +96 -15
- package/dist/cjs/server/client.js +10 -2
- package/dist/cjs/server/handler.js +89 -6
- package/dist/cjs/server/index.js +8 -1
- package/dist/cjs/server/resources/analytics.js +66 -0
- package/dist/cjs/server/resources/identity.js +1 -1
- package/dist/cjs/server/resources/integrations.js +66 -0
- package/dist/cjs/server/resources/sessions.js +1 -1
- package/dist/cjs/server/webhooks.js +127 -0
- package/dist/cjs/this-entry-is-server-only.js +31 -0
- package/dist/esm/cli/doctor.js +206 -0
- package/dist/esm/cli/init.js +20 -8
- package/dist/esm/client/components/access-gate.js +158 -0
- package/dist/esm/client/components/account-sessions.js +21 -19
- package/dist/esm/client/components/app-shell.js +154 -0
- package/dist/esm/client/components/apps-grid.js +2 -1
- package/dist/esm/client/components/integrations-modal.js +92 -0
- package/dist/esm/client/components/motion.js +21 -0
- package/dist/esm/client/components/nav.js +89 -0
- package/dist/esm/client/components/plans.js +39 -37
- package/dist/esm/client/components/readiness.js +3 -1
- package/dist/esm/client/components/side-nav.js +53 -0
- package/dist/esm/client/components/subscription.js +2 -1
- package/dist/esm/client/components/surfaces.js +51 -0
- package/dist/esm/client/components/usage-meter.js +3 -2
- package/dist/esm/client/components/user-menu.js +31 -4
- package/dist/esm/client/index.js +15 -1
- package/dist/esm/client/provider.js +53 -2
- package/dist/esm/client/use-active-path.js +74 -0
- package/dist/esm/errors.js +1 -1
- package/dist/esm/next/index.js +95 -15
- package/dist/esm/server/client.js +10 -2
- package/dist/esm/server/handler.js +89 -6
- package/dist/esm/server/index.js +5 -0
- package/dist/esm/server/resources/analytics.js +62 -0
- package/dist/esm/server/resources/identity.js +1 -1
- package/dist/esm/server/resources/integrations.js +62 -0
- package/dist/esm/server/resources/sessions.js +1 -1
- package/dist/esm/server/webhooks.js +123 -0
- package/dist/esm/this-entry-is-server-only.js +30 -0
- package/dist/theme.css +28 -0
- package/dist/types/cli/doctor.d.ts +23 -0
- package/dist/types/cli/init.d.ts +2 -3
- package/dist/types/client/components/access-gate.d.ts +79 -0
- package/dist/types/client/components/app-shell.d.ts +115 -0
- package/dist/types/client/components/integrations-modal.d.ts +21 -0
- package/dist/types/client/components/motion.d.ts +22 -0
- package/dist/types/client/components/nav.d.ts +110 -0
- package/dist/types/client/components/side-nav.d.ts +31 -0
- package/dist/types/client/components/surfaces.d.ts +51 -0
- package/dist/types/client/index.d.ts +12 -1
- package/dist/types/client/provider.d.ts +38 -3
- package/dist/types/client/use-active-path.d.ts +8 -0
- package/dist/types/errors.d.ts +1 -1
- package/dist/types/next/index.d.ts +40 -0
- package/dist/types/server/client.d.ts +8 -2
- package/dist/types/server/index.d.ts +17 -2
- package/dist/types/server/resources/analytics.d.ts +31 -0
- package/dist/types/server/resources/identity.d.ts +12 -2
- package/dist/types/server/resources/integrations.d.ts +40 -0
- package/dist/types/server/webhooks.d.ts +67 -0
- package/dist/types/this-entry-is-server-only.d.ts +1 -0
- package/dist/types/types.d.ts +182 -1
- package/package.json +22 -4
package/dist/cjs/next/index.js
CHANGED
|
@@ -41,16 +41,22 @@ exports.createConnectHandlers = createConnectHandlers;
|
|
|
41
41
|
exports.GET = handle;
|
|
42
42
|
exports.POST = handle;
|
|
43
43
|
exports.getConnectSession = getConnectSession;
|
|
44
|
+
exports.requireConnectSession = requireConnectSession;
|
|
45
|
+
const react_1 = require("react");
|
|
44
46
|
const client_js_1 = require("../server/client.js");
|
|
45
47
|
const handler_js_1 = require("../server/handler.js");
|
|
46
48
|
/**
|
|
47
49
|
* Plug-and-play wiring for Next.js.
|
|
48
50
|
*
|
|
49
51
|
* Everything below is server-side. Nothing here is *statically* imported from
|
|
50
|
-
* `next
|
|
51
|
-
* and the edge runtime alike
|
|
52
|
-
* that does reach for `next/headers`
|
|
53
|
-
*
|
|
52
|
+
* `next` itself, so this module loads in App Router route handlers, the Node
|
|
53
|
+
* runtime and the edge runtime alike without a `next` peer dependency. The
|
|
54
|
+
* one place that does reach for `next/headers` (or, in `requireConnectSession`,
|
|
55
|
+
* `next/navigation`) does it behind a dynamic import, inside the function that
|
|
56
|
+
* needs it, and says so when it isn't there. `react` is a real, static
|
|
57
|
+
* dependency though - `getConnectSession`'s automatic per-request memoization
|
|
58
|
+
* (below) is built on its `cache()`, which every App Router project already
|
|
59
|
+
* has.
|
|
54
60
|
*
|
|
55
61
|
* The whole integration is two files:
|
|
56
62
|
*
|
|
@@ -76,7 +82,7 @@ const handler_js_1 = require("../server/handler.js");
|
|
|
76
82
|
* | `CONNECT_SESSION_COOKIE` | no | session cookie name |
|
|
77
83
|
*
|
|
78
84
|
* Register `{your origin}/api/connect/callback` as a redirect URI in the
|
|
79
|
-
* service's
|
|
85
|
+
* service's Integration tab. That is the only step this cannot do for you.
|
|
80
86
|
*
|
|
81
87
|
* No `next.config.ts` changes needed either, including `transpilePackages`:
|
|
82
88
|
* `dist/` is already ES2022 with `"use client"` hoisted to the top of every
|
|
@@ -227,6 +233,35 @@ async function requestFromHeaders() {
|
|
|
227
233
|
const proto = incoming.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
|
|
228
234
|
return new Request(`${proto}://${host}/`, { headers: incoming });
|
|
229
235
|
}
|
|
236
|
+
async function readSession(request, options) {
|
|
237
|
+
const store = options.session ?? cookieSessionStore();
|
|
238
|
+
const token = await store.read(request);
|
|
239
|
+
if (!token)
|
|
240
|
+
return null;
|
|
241
|
+
try {
|
|
242
|
+
return await (options.client ?? connectClient()).identity.getUser(token);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* The ambient case - no explicit `request`, default client, default cookie
|
|
250
|
+
* store - wrapped in React's `cache()` so every call in the same request
|
|
251
|
+
* shares one round trip to Connect. A layout reading the session for a nav
|
|
252
|
+
* avatar and the page beneath it reading it again for a role check hit this
|
|
253
|
+
* exact function; without the wrapper that's two live reads of the same
|
|
254
|
+
* answer.
|
|
255
|
+
*
|
|
256
|
+
* Deliberately its own zero-argument function rather than `cache()` wrapping
|
|
257
|
+
* `getConnectSession` directly - `cache()` keys on argument identity, and
|
|
258
|
+
* `getConnectSession`'s default `options = {}` is a fresh object on every
|
|
259
|
+
* call, so wrapping it as-is would never hit the cache for the very call
|
|
260
|
+
* pattern this exists to speed up.
|
|
261
|
+
*/
|
|
262
|
+
const cachedAmbientSession = (0, react_1.cache)(async () => {
|
|
263
|
+
return readSession(await requestFromHeaders(), {});
|
|
264
|
+
});
|
|
230
265
|
/**
|
|
231
266
|
* The signed-in user for a Server Component, Route Handler or Server Action,
|
|
232
267
|
* or `null`.
|
|
@@ -237,6 +272,9 @@ async function requestFromHeaders() {
|
|
|
237
272
|
* if (!session) redirect("/api/connect/signin?next=/dashboard");
|
|
238
273
|
* ```
|
|
239
274
|
*
|
|
275
|
+
* `requireConnectSession()` below is that exact check in one call, for the
|
|
276
|
+
* common case of "redirect if signed out."
|
|
277
|
+
*
|
|
240
278
|
* Pass a `Request` to use it somewhere `next/headers` does not exist - a
|
|
241
279
|
* framework-agnostic handler, a test, a custom server. Both forms take the
|
|
242
280
|
* same path afterwards; the argument is only how the cookie is found.
|
|
@@ -248,18 +286,61 @@ async function requestFromHeaders() {
|
|
|
248
286
|
*
|
|
249
287
|
* Returns `null` for every failure - an expired session and a missing one both
|
|
250
288
|
* mean "not signed in", and a page guard should not need a try/catch.
|
|
289
|
+
*
|
|
290
|
+
* **Memoized per request for the plain, zero-argument call** - the shape
|
|
291
|
+
* every page, layout and Server Action actually uses - via React's `cache()`.
|
|
292
|
+
* Passing an explicit `request`, `client` or `session` bypasses the cache
|
|
293
|
+
* and always makes a live call: those are for tests and non-ambient
|
|
294
|
+
* callers, where a fresh read every time is the point, not an oversight.
|
|
251
295
|
*/
|
|
252
296
|
async function getConnectSession(request, options = {}) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
297
|
+
if (!request && !options.client && !options.session)
|
|
298
|
+
return cachedAmbientSession();
|
|
299
|
+
return readSession(request ?? (await requestFromHeaders()), options);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* `getConnectSession()`, minus the boilerplate of checking it and redirecting
|
|
303
|
+
* yourself:
|
|
304
|
+
*
|
|
305
|
+
* ```tsx
|
|
306
|
+
* // app/dashboard/page.tsx
|
|
307
|
+
* const session = await requireConnectSession();
|
|
308
|
+
* ```
|
|
309
|
+
*
|
|
310
|
+
* Redirects to `/signin` under the same `basePath` as `getConnectSession`'s
|
|
311
|
+
* default (`/api/connect`) when signed out - pass `signInPath` if you mounted
|
|
312
|
+
* the bridge somewhere else, and `next` to send the visitor back to where
|
|
313
|
+
* they were once they're signed in. Only sensible in a Server Component,
|
|
314
|
+
* Route Handler or Server Action, the same three places `redirect()` from
|
|
315
|
+
* `next/navigation` itself works.
|
|
316
|
+
*
|
|
317
|
+
* A thin wrapper, not a framework of its own - reach for `getConnectSession`
|
|
318
|
+
* plus your own check when "not signed in" isn't "redirect to sign-in"
|
|
319
|
+
* (an API route returning 401, say). Role and entitlement checks stay
|
|
320
|
+
* outside this function on purpose: `session.serviceRole.granted` and
|
|
321
|
+
* `connect.entitlements.*` are one line each on top of the session this
|
|
322
|
+
* already gives you, and where that line redirects (a plans page, a
|
|
323
|
+
* "forbidden" screen) is a decision only your app can make - see
|
|
324
|
+
* "Authentication & authorization" for the pattern.
|
|
325
|
+
*/
|
|
326
|
+
async function requireConnectSession(request, options = {}) {
|
|
327
|
+
const session = await getConnectSession(request, options);
|
|
328
|
+
if (session)
|
|
329
|
+
return session;
|
|
330
|
+
const signIn = options.signInPath ?? "/api/connect/signin";
|
|
331
|
+
return redirectTo(options.next ? `${signIn}?next=${encodeURIComponent(options.next)}` : signIn);
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* `next/navigation`'s `redirect()`, imported the same dynamic way as
|
|
335
|
+
* `next/headers` above and for the same reason: no static dependency on
|
|
336
|
+
* `next`'s own types at this package's build time. Typed by hand rather than
|
|
337
|
+
* imported, since that's the only way to erase them - `redirect()` itself is
|
|
338
|
+
* typed `never`, which is what lets `requireConnectSession` return here
|
|
339
|
+
* without its own `return` after the call.
|
|
340
|
+
*/
|
|
341
|
+
async function redirectTo(url) {
|
|
342
|
+
const { redirect } = (await Promise.resolve().then(() => __importStar(require("next/navigation"))));
|
|
343
|
+
return redirect(url);
|
|
263
344
|
}
|
|
264
345
|
var handler_js_2 = require("../server/handler.js");
|
|
265
346
|
Object.defineProperty(exports, "createConnectHandler", { enumerable: true, get: function () { return handler_js_2.createConnectHandler; } });
|
|
@@ -8,7 +8,9 @@ const billing_js_1 = require("./resources/billing.js");
|
|
|
8
8
|
const drive_js_1 = require("./resources/drive.js");
|
|
9
9
|
const entitlements_js_1 = require("./resources/entitlements.js");
|
|
10
10
|
const identity_js_1 = require("./resources/identity.js");
|
|
11
|
+
const integrations_js_1 = require("./resources/integrations.js");
|
|
11
12
|
const logs_js_1 = require("./resources/logs.js");
|
|
13
|
+
const analytics_js_1 = require("./resources/analytics.js");
|
|
12
14
|
const otp_js_1 = require("./resources/otp.js");
|
|
13
15
|
const sessions_js_1 = require("./resources/sessions.js");
|
|
14
16
|
exports.DEFAULT_BASE_URL = "https://connect.cosmoops.com";
|
|
@@ -79,12 +81,16 @@ class ConnectClient {
|
|
|
79
81
|
otp;
|
|
80
82
|
/** Feature events into the Console's audit log, filterable to your service. */
|
|
81
83
|
logs;
|
|
84
|
+
/** Emit product events and read back this service's aggregated numbers. */
|
|
85
|
+
analytics;
|
|
82
86
|
/** This Connect account's own login sessions - account-wide, not scoped to this service. Requires the `sessions:manage` scope. */
|
|
83
87
|
sessions;
|
|
88
|
+
/** Composio-hosted third-party connections (Google Calendar, Slack, ...) an organization has connected. */
|
|
89
|
+
integrations;
|
|
84
90
|
constructor(options) {
|
|
85
91
|
assertNotBrowser();
|
|
86
92
|
if (!options.apiKey) {
|
|
87
|
-
throw new TypeError("ConnectClient requires `apiKey` - the plaintext shown once in the
|
|
93
|
+
throw new TypeError("ConnectClient requires `apiKey` - the plaintext shown once in the Integration tab.");
|
|
88
94
|
}
|
|
89
95
|
const appBaseUrl = (options.baseUrl ?? exports.DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
90
96
|
const transport = {
|
|
@@ -106,9 +112,11 @@ class ConnectClient {
|
|
|
106
112
|
this.ai = new ai_js_1.AiResource(transport);
|
|
107
113
|
this.otp = new otp_js_1.OtpResource(transport);
|
|
108
114
|
this.logs = new logs_js_1.LogsResource(transport);
|
|
115
|
+
this.analytics = new analytics_js_1.AnalyticsResource(transport);
|
|
109
116
|
this.sessions = new sessions_js_1.SessionsResource(transport, options.clientId ?? null);
|
|
117
|
+
this.integrations = new integrations_js_1.IntegrationsResource(transport);
|
|
110
118
|
}
|
|
111
119
|
}
|
|
112
120
|
exports.ConnectClient = ConnectClient;
|
|
113
121
|
/** Kept in step with `package.json` by `sdk.test.ts`. */
|
|
114
|
-
exports.SDK_VERSION = "0.
|
|
122
|
+
exports.SDK_VERSION = "0.6.0";
|
|
@@ -104,6 +104,7 @@ function createConnectHandler(options) {
|
|
|
104
104
|
const { client, session } = options;
|
|
105
105
|
const defaultRedirect = options.defaultRedirect ?? "/";
|
|
106
106
|
const stateCookieName = options.stateCookieName ?? "connect_oauth_state";
|
|
107
|
+
const silentCookieName = `${stateCookieName}_silent`;
|
|
107
108
|
const callbackPath = `${basePath}/callback`;
|
|
108
109
|
/**
|
|
109
110
|
* Short-lived, httpOnly, and `SameSite=Lax` - `Strict` would not be sent on
|
|
@@ -124,6 +125,32 @@ function createConnectHandler(options) {
|
|
|
124
125
|
.filter(Boolean)
|
|
125
126
|
.join("; ");
|
|
126
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Marks the in-flight round trip as a silent (`prompt=none`) one, so the
|
|
130
|
+
* callback can tell a *probe that found nobody* from a *sign-in that
|
|
131
|
+
* failed*. The two arrive as the identical `error=login_required`, and they
|
|
132
|
+
* need opposite handling - see the callback.
|
|
133
|
+
*
|
|
134
|
+
* A cookie of its own rather than a flag folded into the state cookie:
|
|
135
|
+
* that one is parsed by splitting on `:` with the destination URL rejoined
|
|
136
|
+
* from the remainder, so smuggling a third field through it is exactly the
|
|
137
|
+
* kind of encoding trick that breaks the first time a `next` URL contains
|
|
138
|
+
* something unexpected. Same attributes and lifetime as the state cookie
|
|
139
|
+
* it travels beside.
|
|
140
|
+
*/
|
|
141
|
+
function silentCookie(value, secure) {
|
|
142
|
+
const cleared = value === "";
|
|
143
|
+
return [
|
|
144
|
+
`${silentCookieName}=${encodeURIComponent(value)}`,
|
|
145
|
+
`Path=${basePath}`,
|
|
146
|
+
"HttpOnly",
|
|
147
|
+
"SameSite=Lax",
|
|
148
|
+
secure ? "Secure" : "",
|
|
149
|
+
cleared ? "Max-Age=0" : "Max-Age=900",
|
|
150
|
+
]
|
|
151
|
+
.filter(Boolean)
|
|
152
|
+
.join("; ");
|
|
153
|
+
}
|
|
127
154
|
async function requireToken(request) {
|
|
128
155
|
const token = await session.read(request);
|
|
129
156
|
if (!token) {
|
|
@@ -169,6 +196,37 @@ function createConnectHandler(options) {
|
|
|
169
196
|
const { user } = await whoami(request);
|
|
170
197
|
return json({ apps: await client.apps.list(user.organizationId) });
|
|
171
198
|
},
|
|
199
|
+
"GET /integrations": async (request) => {
|
|
200
|
+
const { user } = await whoami(request);
|
|
201
|
+
return json({ toolkits: await client.integrations.list(user.organizationId) });
|
|
202
|
+
},
|
|
203
|
+
"POST /integrations/connect": async (request) => {
|
|
204
|
+
const { user } = await whoami(request);
|
|
205
|
+
const body = (await request.json().catch(() => null));
|
|
206
|
+
if (!body?.toolkitSlug || !body?.returnUrl) {
|
|
207
|
+
throw new errors_js_1.ConnectError({
|
|
208
|
+
status: 400,
|
|
209
|
+
code: "validation_error",
|
|
210
|
+
message: "toolkitSlug and returnUrl are required",
|
|
211
|
+
endpoint: "POST /integrations/connect",
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return json(await client.integrations.initiateConnect(user.organizationId, body.toolkitSlug, body.returnUrl));
|
|
215
|
+
},
|
|
216
|
+
"POST /integrations/disconnect": async (request) => {
|
|
217
|
+
const { user } = await whoami(request);
|
|
218
|
+
const body = (await request.json().catch(() => null));
|
|
219
|
+
if (!body?.toolkitSlug) {
|
|
220
|
+
throw new errors_js_1.ConnectError({
|
|
221
|
+
status: 400,
|
|
222
|
+
code: "validation_error",
|
|
223
|
+
message: "toolkitSlug is required",
|
|
224
|
+
endpoint: "POST /integrations/disconnect",
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
await client.integrations.disconnect(user.organizationId, body.toolkitSlug);
|
|
228
|
+
return json({ ok: true });
|
|
229
|
+
},
|
|
172
230
|
// Account-wide, not scoped to this service - see `SessionsResource`'s
|
|
173
231
|
// doc comment. `requireToken` alone identifies the caller; the target
|
|
174
232
|
// `sessionId` on the revoke routes travels in the JSON body, not the
|
|
@@ -208,14 +266,19 @@ function createConnectHandler(options) {
|
|
|
208
266
|
const secure = url.protocol === "https:";
|
|
209
267
|
const state = (0, identity_js_1.createState)();
|
|
210
268
|
const next = safeRedirect(url.searchParams.get("next"), defaultRedirect);
|
|
269
|
+
const requestedPrompt = url.searchParams.get("prompt");
|
|
270
|
+
const prompt = requestedPrompt === "create" || requestedPrompt === "none" ? requestedPrompt : undefined;
|
|
211
271
|
const authorizeUrl = client.identity.authorizeUrl({
|
|
212
272
|
redirectUri: `${url.origin}${callbackPath}`,
|
|
213
273
|
state,
|
|
214
|
-
...(
|
|
274
|
+
...(prompt ? { prompt } : {}),
|
|
215
275
|
});
|
|
216
276
|
// State and destination travel together in one cookie so the callback
|
|
217
277
|
// cannot end up with one and not the other.
|
|
218
|
-
|
|
278
|
+
const cookies = [stateCookie(`${state}:${next}`, secure)];
|
|
279
|
+
if (prompt === "none")
|
|
280
|
+
cookies.push(silentCookie("1", secure));
|
|
281
|
+
return redirect(authorizeUrl, cookies);
|
|
219
282
|
},
|
|
220
283
|
/**
|
|
221
284
|
* The other end of the round trip. Verifies `state`, exchanges the code,
|
|
@@ -232,10 +295,29 @@ function createConnectHandler(options) {
|
|
|
232
295
|
if (!cookieState || !sameState(cookieState, returnedState)) {
|
|
233
296
|
// Either a forged callback or a genuinely stale tab. Neither should
|
|
234
297
|
// mint a session, and neither is worth an error page.
|
|
235
|
-
return redirect(`${next}${next.includes("?") ? "&" : "?"}connect_error=state_mismatch`, [
|
|
298
|
+
return redirect(`${next}${next.includes("?") ? "&" : "?"}connect_error=state_mismatch`, [
|
|
299
|
+
clearState,
|
|
300
|
+
silentCookie("", secure),
|
|
301
|
+
]);
|
|
236
302
|
}
|
|
303
|
+
const wasSilent = readCookie(request, silentCookieName) === "1";
|
|
304
|
+
const clearSilent = silentCookie("", secure);
|
|
237
305
|
const error = url.searchParams.get("error");
|
|
238
306
|
if (error) {
|
|
307
|
+
// A silent (`prompt=none`) probe that came back empty-handed. All
|
|
308
|
+
// three of its outcomes mean the same thing to the app - "carry on
|
|
309
|
+
// signed out" - so it returns to `next` quietly: no retry, and no
|
|
310
|
+
// `connect_error`, because nothing went wrong.
|
|
311
|
+
//
|
|
312
|
+
// Getting this wrong is not subtle. The retry below fires on exactly
|
|
313
|
+
// the `login_required` a silent probe returns, so without this branch
|
|
314
|
+
// every silent attempt for a signed-out visitor would escalate itself
|
|
315
|
+
// into a full interactive sign-in redirect - turning "quietly check
|
|
316
|
+
// if they're already here" into "force everyone to log in", which is
|
|
317
|
+
// the precise opposite of the feature.
|
|
318
|
+
if (wasSilent) {
|
|
319
|
+
return redirect(next, [clearState, clearSilent]);
|
|
320
|
+
}
|
|
239
321
|
// `login_required` is normal traffic, not a fault: a new account gets
|
|
240
322
|
// pulled out to their inbox to verify, and the ~15 minute request
|
|
241
323
|
// expires before they return. Connect has already signed them in, so
|
|
@@ -245,15 +327,16 @@ function createConnectHandler(options) {
|
|
|
245
327
|
const retry = new URL(`${url.origin}${basePath}/signin`);
|
|
246
328
|
retry.searchParams.set("next", next);
|
|
247
329
|
retry.searchParams.set("connect_retry", "1");
|
|
248
|
-
return redirect(retry.toString(), [clearState]);
|
|
330
|
+
return redirect(retry.toString(), [clearState, clearSilent]);
|
|
249
331
|
}
|
|
250
332
|
return redirect(`${next}${next.includes("?") ? "&" : "?"}connect_error=${encodeURIComponent(error)}`, [
|
|
251
333
|
clearState,
|
|
334
|
+
clearSilent,
|
|
252
335
|
]);
|
|
253
336
|
}
|
|
254
337
|
const code = url.searchParams.get("code");
|
|
255
338
|
if (!code) {
|
|
256
|
-
return redirect(`${next}${next.includes("?") ? "&" : "?"}connect_error=missing_code`, [clearState]);
|
|
339
|
+
return redirect(`${next}${next.includes("?") ? "&" : "?"}connect_error=missing_code`, [clearState, clearSilent]);
|
|
257
340
|
}
|
|
258
341
|
const result = await client.identity.exchangeCode({
|
|
259
342
|
code,
|
|
@@ -262,7 +345,7 @@ function createConnectHandler(options) {
|
|
|
262
345
|
// Verified before the session is stored, not after - an unverifiable
|
|
263
346
|
// token must never become a signed-in user.
|
|
264
347
|
await client.identity.verifyIdToken(result.idToken);
|
|
265
|
-
const response = redirect(next, [clearState]);
|
|
348
|
+
const response = redirect(next, [clearState, clearSilent]);
|
|
266
349
|
await session.write(request, response, result.sessionToken);
|
|
267
350
|
return response;
|
|
268
351
|
},
|
package/dist/cjs/server/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.createState = exports.createConnectHandler = exports.SDK_VERSION = exports.DEFAULT_BASE_URL = exports.ConnectClient = void 0;
|
|
3
|
+
exports.verifyConnectWebhook = exports.createConnectWebhookHandler = exports.createState = exports.createConnectHandler = exports.SDK_VERSION = exports.DEFAULT_BASE_URL = exports.ConnectClient = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* Server-only half of `@cosmoops/connect-sdk`.
|
|
6
6
|
*
|
|
@@ -16,3 +16,10 @@ var handler_js_1 = require("./handler.js");
|
|
|
16
16
|
Object.defineProperty(exports, "createConnectHandler", { enumerable: true, get: function () { return handler_js_1.createConnectHandler; } });
|
|
17
17
|
var identity_js_1 = require("./resources/identity.js");
|
|
18
18
|
Object.defineProperty(exports, "createState", { enumerable: true, get: function () { return identity_js_1.createState; } });
|
|
19
|
+
/**
|
|
20
|
+
* Webhook verification and a ready-made POST route. Server-only for the
|
|
21
|
+
* obvious reason: the signing secret must never reach a browser bundle.
|
|
22
|
+
*/
|
|
23
|
+
var webhooks_js_1 = require("./webhooks.js");
|
|
24
|
+
Object.defineProperty(exports, "createConnectWebhookHandler", { enumerable: true, get: function () { return webhooks_js_1.createConnectWebhookHandler; } });
|
|
25
|
+
Object.defineProperty(exports, "verifyConnectWebhook", { enumerable: true, get: function () { return webhooks_js_1.verifyConnectWebhook; } });
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AnalyticsResource = void 0;
|
|
4
|
+
const http_js_1 = require("../http.js");
|
|
5
|
+
/**
|
|
6
|
+
* Your service's own analytics: emit product events, and read back the daily
|
|
7
|
+
* numbers Connect aggregates from them.
|
|
8
|
+
*
|
|
9
|
+
* There is no way to name a service in either call. The scope is always the one
|
|
10
|
+
* your API key belongs to - that is the endpoint's security model, not an
|
|
11
|
+
* omission.
|
|
12
|
+
*/
|
|
13
|
+
class AnalyticsResource {
|
|
14
|
+
transport;
|
|
15
|
+
constructor(transport) {
|
|
16
|
+
this.transport = transport;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Daily series for the requested metrics, up to 90 days.
|
|
20
|
+
*
|
|
21
|
+
* **Throws on failure**, unlike `emit` below. A read whose result you are
|
|
22
|
+
* about to render has to be able to say "I could not load"; silently
|
|
23
|
+
* returning an empty series would draw a chart of zeroes.
|
|
24
|
+
*/
|
|
25
|
+
async series(query) {
|
|
26
|
+
const params = new URLSearchParams({
|
|
27
|
+
metrics: query.metrics.join(","),
|
|
28
|
+
from: query.from,
|
|
29
|
+
to: query.to,
|
|
30
|
+
});
|
|
31
|
+
return (0, http_js_1.request)(this.transport, {
|
|
32
|
+
method: "GET",
|
|
33
|
+
path: `/analytics?${params.toString()}`,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Never throws - the same judgement `LogsResource.write` makes, for the same
|
|
38
|
+
* reason: telemetry that takes down the code path it was observing is worse
|
|
39
|
+
* than a lost event. A failure is reported through `delivered`.
|
|
40
|
+
*
|
|
41
|
+
* Up to 50 events per call. Pass `sessionToken` on an event to attribute it
|
|
42
|
+
* to the signed-in user; without one it still lands, just unattributed.
|
|
43
|
+
*/
|
|
44
|
+
async emit(events) {
|
|
45
|
+
const normalized = events.map((event) => ({
|
|
46
|
+
...event,
|
|
47
|
+
occurredAt: event.occurredAt instanceof Date ? event.occurredAt.toISOString() : event.occurredAt,
|
|
48
|
+
}));
|
|
49
|
+
try {
|
|
50
|
+
const result = await (0, http_js_1.request)(this.transport, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
path: "/events",
|
|
53
|
+
json: { events: normalized },
|
|
54
|
+
});
|
|
55
|
+
return {
|
|
56
|
+
delivered: true,
|
|
57
|
+
accepted: result?.accepted ?? events.length,
|
|
58
|
+
attributed: result?.attributed ?? 0,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return { delivered: false, accepted: 0, attributed: 0 };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
exports.AnalyticsResource = AnalyticsResource;
|
|
@@ -181,7 +181,7 @@ class IdentityResource {
|
|
|
181
181
|
throw new errors_js_1.ConnectError({
|
|
182
182
|
status: 0,
|
|
183
183
|
code: "missing_client_id",
|
|
184
|
-
message: "identity.* needs `clientId`. Pass it to `new ConnectClient({ clientId, apiKey })` - it is shown in this service's
|
|
184
|
+
message: "identity.* needs `clientId`. Pass it to `new ConnectClient({ clientId, apiKey })` - it is shown in this service's Integration tab, under Connect client.",
|
|
185
185
|
endpoint: "identity",
|
|
186
186
|
});
|
|
187
187
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.IntegrationsResource = void 0;
|
|
4
|
+
const http_js_1 = require("../http.js");
|
|
5
|
+
/**
|
|
6
|
+
* Composio-hosted third-party connections (Google Calendar, Slack,
|
|
7
|
+
* Twitter/X, ...) an organization has connected. Connect proxies every call
|
|
8
|
+
* here - your service never sees or holds the underlying OAuth token, which
|
|
9
|
+
* stays with Composio.
|
|
10
|
+
*/
|
|
11
|
+
class IntegrationsResource {
|
|
12
|
+
transport;
|
|
13
|
+
constructor(transport) {
|
|
14
|
+
this.transport = transport;
|
|
15
|
+
}
|
|
16
|
+
/** Enabled toolkits and this organization's connection status for each. */
|
|
17
|
+
async list(organizationId) {
|
|
18
|
+
const result = await (0, http_js_1.request)(this.transport, {
|
|
19
|
+
method: "GET",
|
|
20
|
+
path: "/integrations",
|
|
21
|
+
query: { organizationId },
|
|
22
|
+
});
|
|
23
|
+
// `?? []` for the same reason `AppsResource.list` has it - a collection
|
|
24
|
+
// every caller immediately maps or measures.
|
|
25
|
+
return result.toolkits ?? [];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Starts a Composio-hosted connection for one toolkit. The returned
|
|
29
|
+
* `redirectUrl` is where the browser completes the provider's OAuth
|
|
30
|
+
* screen, then Composio bounces it back to `returnUrl`.
|
|
31
|
+
*
|
|
32
|
+
* **The connection is not active when this returns.** Treat the toolkit
|
|
33
|
+
* as `pending` until a later `list()` call reports `active` - only
|
|
34
|
+
* Connect's own webhook from Composio confirms that, never the redirect
|
|
35
|
+
* itself. Reconnecting a disconnected/failed toolkit calls this same
|
|
36
|
+
* method - there is no separate reconnect.
|
|
37
|
+
*
|
|
38
|
+
* Meant to be called on behalf of a signed-in org admin (this is what the
|
|
39
|
+
* bridge's fixed `POST /integrations/connect` route does), not as a bare
|
|
40
|
+
* background action with no admin present to complete the OAuth screen.
|
|
41
|
+
*/
|
|
42
|
+
async initiateConnect(organizationId, toolkitSlug, returnUrl) {
|
|
43
|
+
return (0, http_js_1.request)(this.transport, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
path: "/integrations/connect",
|
|
46
|
+
json: { organizationId, toolkitSlug, returnUrl },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
/** Idempotent - disconnecting a toolkit that was never connected, or is already disconnected, is a success. */
|
|
50
|
+
async disconnect(organizationId, toolkitSlug) {
|
|
51
|
+
return (0, http_js_1.request)(this.transport, {
|
|
52
|
+
method: "POST",
|
|
53
|
+
path: "/integrations/disconnect",
|
|
54
|
+
json: { organizationId, toolkitSlug },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** Calls a Composio action against this organization's connected account for the named toolkit. */
|
|
58
|
+
async execute(organizationId, input) {
|
|
59
|
+
return (0, http_js_1.request)(this.transport, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
path: "/integrations/actions/execute",
|
|
62
|
+
json: { organizationId, ...input },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
exports.IntegrationsResource = IntegrationsResource;
|
|
@@ -72,7 +72,7 @@ class SessionsResource {
|
|
|
72
72
|
throw new errors_js_1.ConnectError({
|
|
73
73
|
status: 0,
|
|
74
74
|
code: "missing_client_id",
|
|
75
|
-
message: "sessions.* needs `clientId`. Pass it to `new ConnectClient({ clientId, apiKey })` - it is shown in this service's
|
|
75
|
+
message: "sessions.* needs `clientId`. Pass it to `new ConnectClient({ clientId, apiKey })` - it is shown in this service's Integration tab, under Connect client.",
|
|
76
76
|
endpoint: "sessions",
|
|
77
77
|
});
|
|
78
78
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.verifyConnectWebhook = verifyConnectWebhook;
|
|
4
|
+
exports.createConnectWebhookHandler = createConnectWebhookHandler;
|
|
5
|
+
const node_crypto_1 = require("node:crypto");
|
|
6
|
+
/**
|
|
7
|
+
* How far out of date a timestamp may be. Five minutes, the same value
|
|
8
|
+
* Connect signs against and the same one Stripe uses - so a backend that
|
|
9
|
+
* already verifies Stripe webhooks needs no new assumptions.
|
|
10
|
+
*/
|
|
11
|
+
const TIMESTAMP_TOLERANCE_SECONDS = 300;
|
|
12
|
+
const SIGNATURE_HEADER = "connect-signature";
|
|
13
|
+
/**
|
|
14
|
+
* Verifies a `Connect-Signature` header against the raw request body.
|
|
15
|
+
*
|
|
16
|
+
* Exported on its own for anyone wiring this into a framework
|
|
17
|
+
* `createConnectWebhookHandler` does not cover. The **raw** body matters:
|
|
18
|
+
* verify before parsing, and never re-serialise - `JSON.stringify(await
|
|
19
|
+
* req.json())` produces a different string whenever key order or number
|
|
20
|
+
* formatting differs, and the signature will not match.
|
|
21
|
+
*
|
|
22
|
+
* Returns a boolean and never explains which check failed. Reporting "bad
|
|
23
|
+
* timestamp" separately from "bad signature" tells whoever is probing which
|
|
24
|
+
* half of a forgery to keep working on.
|
|
25
|
+
*/
|
|
26
|
+
function verifyConnectWebhook(rawBody, signatureHeader, secret, now = new Date()) {
|
|
27
|
+
if (!signatureHeader)
|
|
28
|
+
return false;
|
|
29
|
+
const parts = new Map(signatureHeader
|
|
30
|
+
.split(",")
|
|
31
|
+
.map((part) => part.trim().split("="))
|
|
32
|
+
.filter((pair) => pair.length === 2)
|
|
33
|
+
.map(([key, value]) => [key, value]));
|
|
34
|
+
const timestamp = Number(parts.get("t"));
|
|
35
|
+
const presented = parts.get("v1");
|
|
36
|
+
if (!presented || !Number.isFinite(timestamp))
|
|
37
|
+
return false;
|
|
38
|
+
// Absolute difference: a timestamp from the future is as much a sign of a
|
|
39
|
+
// forgery as a stale one, and accepting it would let somebody mint a
|
|
40
|
+
// signature that stays valid indefinitely.
|
|
41
|
+
const nowSeconds = Math.floor(now.getTime() / 1000);
|
|
42
|
+
if (Math.abs(nowSeconds - timestamp) > TIMESTAMP_TOLERANCE_SECONDS)
|
|
43
|
+
return false;
|
|
44
|
+
const expected = (0, node_crypto_1.createHmac)("sha256", secret)
|
|
45
|
+
.update(`${timestamp}.${rawBody}`)
|
|
46
|
+
.digest("hex");
|
|
47
|
+
// `timingSafeEqual` throws on a length mismatch, and a wrong-length hex
|
|
48
|
+
// string is exactly what a probe looks like - so lengths are compared
|
|
49
|
+
// first and only equal-length buffers reach it.
|
|
50
|
+
if (presented.length !== expected.length)
|
|
51
|
+
return false;
|
|
52
|
+
return (0, node_crypto_1.timingSafeEqual)(Buffer.from(presented, "utf8"), Buffer.from(expected, "utf8"));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A ready-made `POST` route for Connect's webhooks.
|
|
56
|
+
*
|
|
57
|
+
* ```ts
|
|
58
|
+
* export const POST = createConnectWebhookHandler({
|
|
59
|
+
* secret: process.env.CONNECT_WEBHOOK_SECRET!,
|
|
60
|
+
* on: {
|
|
61
|
+
* "organization.deletion_scheduled": ({ organizationId, purgeAfter }) =>
|
|
62
|
+
* freeze(organizationId, purgeAfter),
|
|
63
|
+
* "organization.deletion_cancelled": ({ organizationId }) => unfreeze(organizationId),
|
|
64
|
+
* "organization.deleted": ({ organizationId }) => purge(organizationId),
|
|
65
|
+
* },
|
|
66
|
+
* })
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* Three behaviours worth knowing, because each of them is a decision rather
|
|
70
|
+
* than an accident:
|
|
71
|
+
*
|
|
72
|
+
* **An unverified request gets a 401 and nothing else happens.** No parsing,
|
|
73
|
+
* no logging of the body, no handler. The endpoint is a public URL, so
|
|
74
|
+
* anything reachable before the signature check is reachable by anyone.
|
|
75
|
+
*
|
|
76
|
+
* **An unhandled event type is a 200.** Connect adds event types over time,
|
|
77
|
+
* and answering 4xx to one you have not written code for would dead-letter
|
|
78
|
+
* it immediately and, after enough of them, disable your endpoint. Silence
|
|
79
|
+
* is the correct answer to "we do not care about this one".
|
|
80
|
+
*
|
|
81
|
+
* **A throwing handler produces a 500, on purpose.** That is what makes
|
|
82
|
+
* Connect retry. Swallowing the error would acknowledge an event you failed
|
|
83
|
+
* to process, and the retry ladder is the only thing standing between a
|
|
84
|
+
* transient failure and a permanently missed deletion.
|
|
85
|
+
*
|
|
86
|
+
* Delivery is at-least-once and unordered. Dedupe on `envelope.id`, and
|
|
87
|
+
* reconcile against the readiness endpoint rather than trusting arrival
|
|
88
|
+
* order - the pull channel is authoritative, this one is fast.
|
|
89
|
+
*/
|
|
90
|
+
function createConnectWebhookHandler(options) {
|
|
91
|
+
return async function POST(request) {
|
|
92
|
+
// Read as text and verify before parsing. `JSON.stringify(await
|
|
93
|
+
// request.json())` is not the same string that was signed - key order and
|
|
94
|
+
// number formatting both differ - so a handler that parses first can
|
|
95
|
+
// never verify.
|
|
96
|
+
const rawBody = await request.text();
|
|
97
|
+
const signature = request.headers.get(SIGNATURE_HEADER);
|
|
98
|
+
if (!verifyConnectWebhook(rawBody, signature, options.secret)) {
|
|
99
|
+
return new Response("Invalid signature", { status: 401 });
|
|
100
|
+
}
|
|
101
|
+
let envelope;
|
|
102
|
+
try {
|
|
103
|
+
envelope = JSON.parse(rawBody);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Signed by us and still unparseable is not something a retry fixes.
|
|
107
|
+
return new Response("Malformed payload", { status: 400 });
|
|
108
|
+
}
|
|
109
|
+
const handler = options.on[envelope.type];
|
|
110
|
+
if (!handler)
|
|
111
|
+
return new Response(null, { status: 200 });
|
|
112
|
+
try {
|
|
113
|
+
await handler(envelope.data, envelope);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (options.onError) {
|
|
117
|
+
await options.onError(error, envelope);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
console.error(`Connect webhook ${envelope.type} (${envelope.id}) failed:`, error);
|
|
121
|
+
}
|
|
122
|
+
// Deliberately a 500: it is what makes Connect retry. See the doc above.
|
|
123
|
+
return new Response("Handler failed", { status: 500 });
|
|
124
|
+
}
|
|
125
|
+
return new Response(null, { status: 200 });
|
|
126
|
+
};
|
|
127
|
+
}
|