@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
|
@@ -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
|
-
...(
|
|
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
|
-
|
|
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`, [
|
|
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
|
},
|
package/dist/esm/server/index.js
CHANGED
|
@@ -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
|
|
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
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { request } from "../http.js";
|
|
2
|
+
/**
|
|
3
|
+
* Composio-hosted third-party connections (Google Calendar, Slack,
|
|
4
|
+
* Twitter/X, ...) an organization has connected. Connect proxies every call
|
|
5
|
+
* here - your service never sees or holds the underlying OAuth token, which
|
|
6
|
+
* stays with Composio.
|
|
7
|
+
*/
|
|
8
|
+
export class IntegrationsResource {
|
|
9
|
+
transport;
|
|
10
|
+
constructor(transport) {
|
|
11
|
+
this.transport = transport;
|
|
12
|
+
}
|
|
13
|
+
/** Enabled toolkits and this organization's connection status for each. */
|
|
14
|
+
async list(organizationId) {
|
|
15
|
+
const result = await request(this.transport, {
|
|
16
|
+
method: "GET",
|
|
17
|
+
path: "/integrations",
|
|
18
|
+
query: { organizationId },
|
|
19
|
+
});
|
|
20
|
+
// `?? []` for the same reason `AppsResource.list` has it - a collection
|
|
21
|
+
// every caller immediately maps or measures.
|
|
22
|
+
return result.toolkits ?? [];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Starts a Composio-hosted connection for one toolkit. The returned
|
|
26
|
+
* `redirectUrl` is where the browser completes the provider's OAuth
|
|
27
|
+
* screen, then Composio bounces it back to `returnUrl`.
|
|
28
|
+
*
|
|
29
|
+
* **The connection is not active when this returns.** Treat the toolkit
|
|
30
|
+
* as `pending` until a later `list()` call reports `active` - only
|
|
31
|
+
* Connect's own webhook from Composio confirms that, never the redirect
|
|
32
|
+
* itself. Reconnecting a disconnected/failed toolkit calls this same
|
|
33
|
+
* method - there is no separate reconnect.
|
|
34
|
+
*
|
|
35
|
+
* Meant to be called on behalf of a signed-in org admin (this is what the
|
|
36
|
+
* bridge's fixed `POST /integrations/connect` route does), not as a bare
|
|
37
|
+
* background action with no admin present to complete the OAuth screen.
|
|
38
|
+
*/
|
|
39
|
+
async initiateConnect(organizationId, toolkitSlug, returnUrl) {
|
|
40
|
+
return request(this.transport, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
path: "/integrations/connect",
|
|
43
|
+
json: { organizationId, toolkitSlug, returnUrl },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** Idempotent - disconnecting a toolkit that was never connected, or is already disconnected, is a success. */
|
|
47
|
+
async disconnect(organizationId, toolkitSlug) {
|
|
48
|
+
return request(this.transport, {
|
|
49
|
+
method: "POST",
|
|
50
|
+
path: "/integrations/disconnect",
|
|
51
|
+
json: { organizationId, toolkitSlug },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
/** Calls a Composio action against this organization's connected account for the named toolkit. */
|
|
55
|
+
async execute(organizationId, input) {
|
|
56
|
+
return request(this.transport, {
|
|
57
|
+
method: "POST",
|
|
58
|
+
path: "/integrations/actions/execute",
|
|
59
|
+
json: { organizationId, ...input },
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -69,7 +69,7 @@ export class SessionsResource {
|
|
|
69
69
|
throw new ConnectError({
|
|
70
70
|
status: 0,
|
|
71
71
|
code: "missing_client_id",
|
|
72
|
-
message: "sessions.* needs `clientId`. Pass it to `new ConnectClient({ clientId, apiKey })` - it is shown in this service's
|
|
72
|
+
message: "sessions.* needs `clientId`. Pass it to `new ConnectClient({ clientId, apiKey })` - it is shown in this service's Integration tab, under Connect client.",
|
|
73
73
|
endpoint: "sessions",
|
|
74
74
|
});
|
|
75
75
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* How far out of date a timestamp may be. Five minutes, the same value
|
|
4
|
+
* Connect signs against and the same one Stripe uses - so a backend that
|
|
5
|
+
* already verifies Stripe webhooks needs no new assumptions.
|
|
6
|
+
*/
|
|
7
|
+
const TIMESTAMP_TOLERANCE_SECONDS = 300;
|
|
8
|
+
const SIGNATURE_HEADER = "connect-signature";
|
|
9
|
+
/**
|
|
10
|
+
* Verifies a `Connect-Signature` header against the raw request body.
|
|
11
|
+
*
|
|
12
|
+
* Exported on its own for anyone wiring this into a framework
|
|
13
|
+
* `createConnectWebhookHandler` does not cover. The **raw** body matters:
|
|
14
|
+
* verify before parsing, and never re-serialise - `JSON.stringify(await
|
|
15
|
+
* req.json())` produces a different string whenever key order or number
|
|
16
|
+
* formatting differs, and the signature will not match.
|
|
17
|
+
*
|
|
18
|
+
* Returns a boolean and never explains which check failed. Reporting "bad
|
|
19
|
+
* timestamp" separately from "bad signature" tells whoever is probing which
|
|
20
|
+
* half of a forgery to keep working on.
|
|
21
|
+
*/
|
|
22
|
+
export function verifyConnectWebhook(rawBody, signatureHeader, secret, now = new Date()) {
|
|
23
|
+
if (!signatureHeader)
|
|
24
|
+
return false;
|
|
25
|
+
const parts = new Map(signatureHeader
|
|
26
|
+
.split(",")
|
|
27
|
+
.map((part) => part.trim().split("="))
|
|
28
|
+
.filter((pair) => pair.length === 2)
|
|
29
|
+
.map(([key, value]) => [key, value]));
|
|
30
|
+
const timestamp = Number(parts.get("t"));
|
|
31
|
+
const presented = parts.get("v1");
|
|
32
|
+
if (!presented || !Number.isFinite(timestamp))
|
|
33
|
+
return false;
|
|
34
|
+
// Absolute difference: a timestamp from the future is as much a sign of a
|
|
35
|
+
// forgery as a stale one, and accepting it would let somebody mint a
|
|
36
|
+
// signature that stays valid indefinitely.
|
|
37
|
+
const nowSeconds = Math.floor(now.getTime() / 1000);
|
|
38
|
+
if (Math.abs(nowSeconds - timestamp) > TIMESTAMP_TOLERANCE_SECONDS)
|
|
39
|
+
return false;
|
|
40
|
+
const expected = createHmac("sha256", secret)
|
|
41
|
+
.update(`${timestamp}.${rawBody}`)
|
|
42
|
+
.digest("hex");
|
|
43
|
+
// `timingSafeEqual` throws on a length mismatch, and a wrong-length hex
|
|
44
|
+
// string is exactly what a probe looks like - so lengths are compared
|
|
45
|
+
// first and only equal-length buffers reach it.
|
|
46
|
+
if (presented.length !== expected.length)
|
|
47
|
+
return false;
|
|
48
|
+
return timingSafeEqual(Buffer.from(presented, "utf8"), Buffer.from(expected, "utf8"));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* A ready-made `POST` route for Connect's webhooks.
|
|
52
|
+
*
|
|
53
|
+
* ```ts
|
|
54
|
+
* export const POST = createConnectWebhookHandler({
|
|
55
|
+
* secret: process.env.CONNECT_WEBHOOK_SECRET!,
|
|
56
|
+
* on: {
|
|
57
|
+
* "organization.deletion_scheduled": ({ organizationId, purgeAfter }) =>
|
|
58
|
+
* freeze(organizationId, purgeAfter),
|
|
59
|
+
* "organization.deletion_cancelled": ({ organizationId }) => unfreeze(organizationId),
|
|
60
|
+
* "organization.deleted": ({ organizationId }) => purge(organizationId),
|
|
61
|
+
* },
|
|
62
|
+
* })
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
65
|
+
* Three behaviours worth knowing, because each of them is a decision rather
|
|
66
|
+
* than an accident:
|
|
67
|
+
*
|
|
68
|
+
* **An unverified request gets a 401 and nothing else happens.** No parsing,
|
|
69
|
+
* no logging of the body, no handler. The endpoint is a public URL, so
|
|
70
|
+
* anything reachable before the signature check is reachable by anyone.
|
|
71
|
+
*
|
|
72
|
+
* **An unhandled event type is a 200.** Connect adds event types over time,
|
|
73
|
+
* and answering 4xx to one you have not written code for would dead-letter
|
|
74
|
+
* it immediately and, after enough of them, disable your endpoint. Silence
|
|
75
|
+
* is the correct answer to "we do not care about this one".
|
|
76
|
+
*
|
|
77
|
+
* **A throwing handler produces a 500, on purpose.** That is what makes
|
|
78
|
+
* Connect retry. Swallowing the error would acknowledge an event you failed
|
|
79
|
+
* to process, and the retry ladder is the only thing standing between a
|
|
80
|
+
* transient failure and a permanently missed deletion.
|
|
81
|
+
*
|
|
82
|
+
* Delivery is at-least-once and unordered. Dedupe on `envelope.id`, and
|
|
83
|
+
* reconcile against the readiness endpoint rather than trusting arrival
|
|
84
|
+
* order - the pull channel is authoritative, this one is fast.
|
|
85
|
+
*/
|
|
86
|
+
export function createConnectWebhookHandler(options) {
|
|
87
|
+
return async function POST(request) {
|
|
88
|
+
// Read as text and verify before parsing. `JSON.stringify(await
|
|
89
|
+
// request.json())` is not the same string that was signed - key order and
|
|
90
|
+
// number formatting both differ - so a handler that parses first can
|
|
91
|
+
// never verify.
|
|
92
|
+
const rawBody = await request.text();
|
|
93
|
+
const signature = request.headers.get(SIGNATURE_HEADER);
|
|
94
|
+
if (!verifyConnectWebhook(rawBody, signature, options.secret)) {
|
|
95
|
+
return new Response("Invalid signature", { status: 401 });
|
|
96
|
+
}
|
|
97
|
+
let envelope;
|
|
98
|
+
try {
|
|
99
|
+
envelope = JSON.parse(rawBody);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Signed by us and still unparseable is not something a retry fixes.
|
|
103
|
+
return new Response("Malformed payload", { status: 400 });
|
|
104
|
+
}
|
|
105
|
+
const handler = options.on[envelope.type];
|
|
106
|
+
if (!handler)
|
|
107
|
+
return new Response(null, { status: 200 });
|
|
108
|
+
try {
|
|
109
|
+
await handler(envelope.data, envelope);
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
if (options.onError) {
|
|
113
|
+
await options.onError(error, envelope);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
console.error(`Connect webhook ${envelope.type} (${envelope.id}) failed:`, error);
|
|
117
|
+
}
|
|
118
|
+
// Deliberately a 500: it is what makes Connect retry. See the doc above.
|
|
119
|
+
return new Response("Handler failed", { status: 500 });
|
|
120
|
+
}
|
|
121
|
+
return new Response(null, { status: 200 });
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The module a browser bundle gets if it reaches for `/server` or `/next`.
|
|
3
|
+
*
|
|
4
|
+
* Neither entry may run in a browser: both build a `ConnectClient` around
|
|
5
|
+
* `CONNECT_API_KEY`. Nothing enforced that before - the split was documented
|
|
6
|
+
* and unguarded, so a client component importing `@cosmoops/connect-sdk/server`
|
|
7
|
+
* compiled cleanly and failed later, somewhere else, for reasons that did not
|
|
8
|
+
* mention the import.
|
|
9
|
+
*
|
|
10
|
+
* The key itself does not leak: `CONNECT_API_KEY` has no `NEXT_PUBLIC_` prefix,
|
|
11
|
+
* so Next replaces the read with `undefined` in a client bundle rather than
|
|
12
|
+
* inlining the secret. What ships is the credential-handling code path and a
|
|
13
|
+
* client that authenticates with `undefined` - which 401s against Connect and
|
|
14
|
+
* looks like a server bug. This turns that into a build-time failure naming the
|
|
15
|
+
* entry to import instead.
|
|
16
|
+
*
|
|
17
|
+
* Wired through the `browser` export condition, which Next's client compiler
|
|
18
|
+
* resolves and its server, RSC and SSR passes do not - so the same import stays
|
|
19
|
+
* legal in a Route Handler, a Server Component and a non-Next backend.
|
|
20
|
+
*
|
|
21
|
+
* The filename is the error message. A bundler reaches this module while
|
|
22
|
+
* resolving a *named* import, so it reports "no matching export" and never
|
|
23
|
+
* evaluates the throw below - the path is the only part of that error carrying
|
|
24
|
+
* any meaning, so the path has to say what went wrong. The throw still covers
|
|
25
|
+
* side-effect, default and namespace imports, which do evaluate it.
|
|
26
|
+
*/
|
|
27
|
+
throw new Error('@cosmoops/connect-sdk: "@cosmoops/connect-sdk/server" and "@cosmoops/connect-sdk/next" hold your CONNECT_API_KEY and cannot be imported from browser code.\n' +
|
|
28
|
+
'Import "@cosmoops/connect-sdk/client" instead - its components and hooks call your own origin, and the bridge route reaches Connect for them.\n' +
|
|
29
|
+
'For types only, import from "@cosmoops/connect-sdk" (isomorphic) or use `import type`, which erases at compile time.');
|
|
30
|
+
export {};
|
package/dist/theme.css
CHANGED
|
@@ -50,6 +50,31 @@
|
|
|
50
50
|
--separator: oklch(92% 0.0067 145.67);
|
|
51
51
|
--surface: oklch(100% 0.0034 145.67);
|
|
52
52
|
--surface-secondary: oklch(94% 0.0067 145.67);
|
|
53
|
+
/*
|
|
54
|
+
* The neutral ramp every `secondary`/`tertiary` Button and every default
|
|
55
|
+
* Chip actually paints with - `.button--tertiary` resolves to
|
|
56
|
+
* `--button-bg: var(--default)`, not to `--surface`.
|
|
57
|
+
*
|
|
58
|
+
* Defining these is the difference between "the palette below is the
|
|
59
|
+
* palette" and it merely being most of it. Left out, `--default` and
|
|
60
|
+
* `--surface-tertiary` keep @heroui/styles' stock values, which sit at
|
|
61
|
+
* hue 286 - a cool blue-grey - while every token below is hue 145.67, a
|
|
62
|
+
* warm green-grey. The two ramps are near-neutral enough that neither
|
|
63
|
+
* reads as "blue" or "green" on its own, and precisely similar enough
|
|
64
|
+
* that a grey button next to a grey panel reads as an accident. In dark
|
|
65
|
+
* mode they also disagreed on lightness (27.4% against a 25% panel), so
|
|
66
|
+
* a tertiary button sat a step *above* the panel containing it.
|
|
67
|
+
*
|
|
68
|
+
* Lightness is kept at HeroUI's stock values so component relationships
|
|
69
|
+
* are unchanged; only the hue is brought onto the ramp.
|
|
70
|
+
*
|
|
71
|
+
* Everything ending in `-soft`, plus `--default-hover`/`--default-foreground`,
|
|
72
|
+
* is derived by @heroui/styles via `color-mix` from the base token, so
|
|
73
|
+
* those follow from these and must NOT be redefined here - hardcoding one
|
|
74
|
+
* pins it and stops it tracking a partner's override.
|
|
75
|
+
*/
|
|
76
|
+
--default: oklch(94% 0.0067 145.67);
|
|
77
|
+
--surface-tertiary: oklch(93.73% 0.0067 145.67);
|
|
53
78
|
--danger: oklch(65.32% 0.2359 12.76);
|
|
54
79
|
--danger-foreground: oklch(99.11% 0 0);
|
|
55
80
|
--warning: oklch(79% 0.16 76);
|
|
@@ -70,6 +95,9 @@
|
|
|
70
95
|
--separator: oklch(26% 0.0067 145.67);
|
|
71
96
|
--surface: oklch(21% 0.0067 145.67);
|
|
72
97
|
--surface-secondary: oklch(25% 0.0067 145.67);
|
|
98
|
+
/* See the light block above. */
|
|
99
|
+
--default: oklch(27.4% 0.0067 145.67);
|
|
100
|
+
--surface-tertiary: oklch(27.21% 0.0067 145.67);
|
|
73
101
|
--danger: oklch(65.32% 0.2359 12.76);
|
|
74
102
|
--danger-foreground: oklch(99.11% 0 0);
|
|
75
103
|
--warning: oklch(79% 0.16 76);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `npx @cosmoops/connect-sdk doctor` - checks a wired-up project for the
|
|
3
|
+
* mistakes that `init` cannot prevent.
|
|
4
|
+
*
|
|
5
|
+
* `init` writes the two files it owns. Everything else in the setup is a step
|
|
6
|
+
* someone performs by hand in a file this package must not rewrite - the
|
|
7
|
+
* provider goes into a layout somebody else wrote, the theme import goes into
|
|
8
|
+
* their stylesheet in a specific position, and the redirect URI lives in
|
|
9
|
+
* Connect rather than in the repo. Those are where setup actually breaks, and
|
|
10
|
+
* each one fails in a way that does not name its cause: a missing provider
|
|
11
|
+
* throws from a hook, a mis-ordered theme import silently loses to HeroUI's
|
|
12
|
+
* defaults, and an unregistered redirect URI only shows up as an OAuth error
|
|
13
|
+
* after a round trip to Connect.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately offline. Connect exposes no endpoint that lists a client's
|
|
16
|
+
* registered redirect URIs, so there is nothing to check remotely that would
|
|
17
|
+
* justify the cost - and an offline check needs no credentials, which is what
|
|
18
|
+
* lets this run in CI on a pull request.
|
|
19
|
+
*
|
|
20
|
+
* The exit code is the point: `fail` means the app is broken and returns 1,
|
|
21
|
+
* `warn` means this cannot tell from the outside and returns 0.
|
|
22
|
+
*/
|
|
23
|
+
export declare function runDoctor(argv?: string[]): number;
|
package/dist/types/cli/init.d.ts
CHANGED
|
@@ -22,11 +22,10 @@
|
|
|
22
22
|
*/
|
|
23
23
|
export declare function runInit(argv?: string[]): number;
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
26
|
-
* `check` later does not change how the existing one is invoked.
|
|
25
|
+
* `init` writes the wiring; `doctor` checks the parts `init` cannot write.
|
|
27
26
|
*
|
|
28
27
|
* A bare path runs `init` against it, so both `connect-sdk init ./my-app` and
|
|
29
|
-
* `connect-sdk ./my-app` work. A first argument that is neither
|
|
28
|
+
* `connect-sdk ./my-app` work. A first argument that is neither a command nor
|
|
30
29
|
* an existing directory is a typo, and saying so beats reporting that
|
|
31
30
|
* `./innit` contains no App Router - which is true, unhelpful, and points at
|
|
32
31
|
* the wrong mistake.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { ReadinessBlocker } from "../../types.js";
|
|
3
|
+
export type ConnectAccessGateState = "loading"
|
|
4
|
+
/** No signed-in user - not this component's concern, see the doc comment on `ConnectAccessGate`. */
|
|
5
|
+
| "signed_out"
|
|
6
|
+
/** `organization_suspended` - a platform admin took this workspace offline. Hard block, no dismiss. */
|
|
7
|
+
| "suspended"
|
|
8
|
+
/** `subscription_missing` - nothing to subscribe to yet. Hard block. */
|
|
9
|
+
| "no_subscription"
|
|
10
|
+
/** `onboarding_approval_pending` - waiting on a reviewer. Hard block, no action to offer. */
|
|
11
|
+
| "onboarding_pending"
|
|
12
|
+
/** Any other `blocking` readiness code (verification, audience, seats, ...). Hard block. */
|
|
13
|
+
| "blocked"
|
|
14
|
+
/** `payment_retry_pending` - an automatic dunning retry is scheduled. Not blocking - `children` still render. */
|
|
15
|
+
| "payment_retry"
|
|
16
|
+
/** Nothing outstanding. */
|
|
17
|
+
| "clear";
|
|
18
|
+
export interface ConnectAccessGateResult {
|
|
19
|
+
state: ConnectAccessGateState;
|
|
20
|
+
/** The blocker driving a hard-block state (`suspended`/`no_subscription`/`onboarding_pending`/`blocked`). `null` otherwise. */
|
|
21
|
+
blocker: ReadinessBlocker | null;
|
|
22
|
+
/** The `payment_retry_pending` blocker, only when `state === "payment_retry"`. Carries `graceEndsAt`. */
|
|
23
|
+
paymentRetry: ReadinessBlocker | null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The single composed answer to "can this workspace use my app right now,
|
|
27
|
+
* and if not, which of the four things is it": choose a plan, wait on
|
|
28
|
+
* approval, resolve an overdue payment, or - a suspended workspace - none of
|
|
29
|
+
* the above, because nothing short of a platform admin fixes that one.
|
|
30
|
+
*
|
|
31
|
+
* Reuses the existing single sources of truth rather than re-deriving them:
|
|
32
|
+
* `blockingBlockers()` (`helpers.ts`) for severity, the same readiness read
|
|
33
|
+
* `ConnectReadinessGate`/`ConnectReadinessBanner` already use. `ConnectAccessGate`
|
|
34
|
+
* is a priority ordering over the same data, not a second opinion about it.
|
|
35
|
+
*
|
|
36
|
+
* Priority, first match wins: `suspended` > `no_subscription` >
|
|
37
|
+
* `onboarding_pending` > any other blocking code (`blocked`) >
|
|
38
|
+
* `payment_retry` (advisory - `children` still render) > `clear`.
|
|
39
|
+
*/
|
|
40
|
+
export declare function useConnectAccessGate(): ConnectAccessGateResult;
|
|
41
|
+
export interface ConnectAccessGateProps {
|
|
42
|
+
children: ReactNode;
|
|
43
|
+
/** Replaces the default card for every hard-block state (`suspended`/`no_subscription`/`onboarding_pending`/`blocked`) uniformly. For a different card per state, call `useConnectAccessGate()` directly instead. */
|
|
44
|
+
fallback?: ReactNode;
|
|
45
|
+
/** Shown while the first read is in flight. */
|
|
46
|
+
loading?: ReactNode;
|
|
47
|
+
className?: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Wrap your app's content once - typically in your root layout, alongside
|
|
51
|
+
* (not instead of) your own header/nav - and it renders exactly one of: the
|
|
52
|
+
* hard-block card for a suspended workspace, one with no plan, or one
|
|
53
|
+
* waiting on approval; your `children` plus a dismissible payment-due banner
|
|
54
|
+
* during an automatic dunning retry; or just `children` once everything is
|
|
55
|
+
* clear.
|
|
56
|
+
*
|
|
57
|
+
* **Do not wrap your own plans/pricing page with this.** The `no_subscription`
|
|
58
|
+
* card's button sends someone to Connect's subscribe page directly (see
|
|
59
|
+
* `GateCard`'s doc comment on why - it used to link to your in-app `/plans`
|
|
60
|
+
* instead, which is a dead end once this gate hides that very page too), but
|
|
61
|
+
* a customer can still navigate to your plans page on their own (a nav link,
|
|
62
|
+
* a bookmark) and must actually see it there. Since the gate is mounted once
|
|
63
|
+
* around every route, carving out that exception is your router's job, not
|
|
64
|
+
* a prop on this component: check the current path in your layout/shell and
|
|
65
|
+
* skip wrapping `children` in `<ConnectAccessGate>` for that one route.
|
|
66
|
+
*
|
|
67
|
+
* **Signed-out renders `children`, not a fallback** - deliberately different
|
|
68
|
+
* from `ConnectReadinessGate`/`ConnectSubscriptionGate`, which show nothing
|
|
69
|
+
* until signed in. None of the four states this component composes is a
|
|
70
|
+
* property of a signed-out visitor, and a component mounted around your
|
|
71
|
+
* entire app must not hide a public landing page behind an empty gate. Your
|
|
72
|
+
* own sign-in flow decides what a signed-out visitor sees.
|
|
73
|
+
*
|
|
74
|
+
* **This is a UX gate, not a security boundary** - same caveat as every
|
|
75
|
+
* other gate in this package. The authoritative check is
|
|
76
|
+
* `client.entitlements.getReadiness` on your server, before you do the
|
|
77
|
+
* thing.
|
|
78
|
+
*/
|
|
79
|
+
export declare function ConnectAccessGate({ children, fallback, loading, className }: ConnectAccessGateProps): import("react").JSX.Element;
|