@cosmoops/connect-sdk 0.2.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.md +284 -7
  3. package/dist/cjs/cli/doctor.js +209 -0
  4. package/dist/cjs/cli/init.js +20 -8
  5. package/dist/cjs/client/components/access-gate.js +162 -0
  6. package/dist/cjs/client/components/account-sessions.js +21 -19
  7. package/dist/cjs/client/components/app-shell.js +157 -0
  8. package/dist/cjs/client/components/apps-grid.js +2 -1
  9. package/dist/cjs/client/components/integrations-modal.js +95 -0
  10. package/dist/cjs/client/components/motion.js +27 -0
  11. package/dist/cjs/client/components/nav.js +97 -0
  12. package/dist/cjs/client/components/plans.js +39 -37
  13. package/dist/cjs/client/components/readiness.js +3 -1
  14. package/dist/cjs/client/components/side-nav.js +58 -0
  15. package/dist/cjs/client/components/subscription.js +2 -1
  16. package/dist/cjs/client/components/surfaces.js +54 -0
  17. package/dist/cjs/client/components/usage-meter.js +3 -2
  18. package/dist/cjs/client/components/user-menu.js +30 -3
  19. package/dist/cjs/client/index.js +29 -1
  20. package/dist/cjs/client/provider.js +54 -2
  21. package/dist/cjs/client/use-active-path.js +77 -0
  22. package/dist/cjs/errors.js +1 -1
  23. package/dist/cjs/next/index.js +96 -15
  24. package/dist/cjs/server/client.js +10 -2
  25. package/dist/cjs/server/handler.js +89 -6
  26. package/dist/cjs/server/index.js +8 -1
  27. package/dist/cjs/server/resources/analytics.js +66 -0
  28. package/dist/cjs/server/resources/identity.js +1 -1
  29. package/dist/cjs/server/resources/integrations.js +66 -0
  30. package/dist/cjs/server/resources/sessions.js +1 -1
  31. package/dist/cjs/server/webhooks.js +127 -0
  32. package/dist/cjs/this-entry-is-server-only.js +31 -0
  33. package/dist/esm/cli/doctor.js +206 -0
  34. package/dist/esm/cli/init.js +20 -8
  35. package/dist/esm/client/components/access-gate.js +158 -0
  36. package/dist/esm/client/components/account-sessions.js +21 -19
  37. package/dist/esm/client/components/app-shell.js +154 -0
  38. package/dist/esm/client/components/apps-grid.js +2 -1
  39. package/dist/esm/client/components/integrations-modal.js +92 -0
  40. package/dist/esm/client/components/motion.js +21 -0
  41. package/dist/esm/client/components/nav.js +89 -0
  42. package/dist/esm/client/components/plans.js +39 -37
  43. package/dist/esm/client/components/readiness.js +3 -1
  44. package/dist/esm/client/components/side-nav.js +53 -0
  45. package/dist/esm/client/components/subscription.js +2 -1
  46. package/dist/esm/client/components/surfaces.js +51 -0
  47. package/dist/esm/client/components/usage-meter.js +3 -2
  48. package/dist/esm/client/components/user-menu.js +31 -4
  49. package/dist/esm/client/index.js +15 -1
  50. package/dist/esm/client/provider.js +53 -2
  51. package/dist/esm/client/use-active-path.js +74 -0
  52. package/dist/esm/errors.js +1 -1
  53. package/dist/esm/next/index.js +95 -15
  54. package/dist/esm/server/client.js +10 -2
  55. package/dist/esm/server/handler.js +89 -6
  56. package/dist/esm/server/index.js +5 -0
  57. package/dist/esm/server/resources/analytics.js +62 -0
  58. package/dist/esm/server/resources/identity.js +1 -1
  59. package/dist/esm/server/resources/integrations.js +62 -0
  60. package/dist/esm/server/resources/sessions.js +1 -1
  61. package/dist/esm/server/webhooks.js +123 -0
  62. package/dist/esm/this-entry-is-server-only.js +30 -0
  63. package/dist/theme.css +28 -0
  64. package/dist/types/cli/doctor.d.ts +23 -0
  65. package/dist/types/cli/init.d.ts +2 -3
  66. package/dist/types/client/components/access-gate.d.ts +79 -0
  67. package/dist/types/client/components/app-shell.d.ts +115 -0
  68. package/dist/types/client/components/integrations-modal.d.ts +21 -0
  69. package/dist/types/client/components/motion.d.ts +22 -0
  70. package/dist/types/client/components/nav.d.ts +110 -0
  71. package/dist/types/client/components/side-nav.d.ts +31 -0
  72. package/dist/types/client/components/surfaces.d.ts +51 -0
  73. package/dist/types/client/index.d.ts +12 -1
  74. package/dist/types/client/provider.d.ts +38 -3
  75. package/dist/types/client/use-active-path.d.ts +8 -0
  76. package/dist/types/errors.d.ts +1 -1
  77. package/dist/types/next/index.d.ts +40 -0
  78. package/dist/types/server/client.d.ts +8 -2
  79. package/dist/types/server/index.d.ts +17 -2
  80. package/dist/types/server/resources/analytics.d.ts +31 -0
  81. package/dist/types/server/resources/identity.d.ts +12 -2
  82. package/dist/types/server/resources/integrations.d.ts +40 -0
  83. package/dist/types/server/webhooks.d.ts +67 -0
  84. package/dist/types/this-entry-is-server-only.d.ts +1 -0
  85. package/dist/types/types.d.ts +182 -1
  86. package/package.json +22 -4
@@ -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
  }
@@ -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 Setup tab once SSO is enabled.",
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;
@@ -22,11 +22,10 @@
22
22
  */
23
23
  export declare function runInit(argv?: string[]): number;
24
24
  /**
25
- * Only `init` today. Parsed as a subcommand anyway so adding `doctor` or
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 the command nor
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;