@cosmoops/connect-sdk 0.3.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +125 -0
- package/README.md +215 -6
- package/dist/cjs/cli/doctor.js +3 -3
- package/dist/cjs/cli/init.js +3 -3
- package/dist/cjs/client/components/access-gate.js +162 -0
- package/dist/cjs/client/components/account-sessions.js +24 -22
- package/dist/cjs/client/components/app-shell.js +157 -0
- package/dist/cjs/client/components/apps-grid.js +4 -3
- package/dist/cjs/client/components/feature-gate.js +2 -2
- package/dist/cjs/client/components/integrations-modal.js +95 -0
- package/dist/cjs/client/components/logo.js +3 -3
- package/dist/cjs/client/components/motion.js +27 -0
- package/dist/cjs/client/components/nav.js +97 -0
- package/dist/cjs/client/components/plan-pricing.js +1 -1
- package/dist/cjs/client/components/plans.js +42 -40
- package/dist/cjs/client/components/readiness.js +6 -4
- package/dist/cjs/client/components/side-nav.js +58 -0
- package/dist/cjs/client/components/subscribe-dialog.js +2 -2
- package/dist/cjs/client/components/subscription.js +3 -2
- package/dist/cjs/client/components/surfaces.js +54 -0
- package/dist/cjs/client/components/usage-meter.js +4 -3
- package/dist/cjs/client/components/user-menu.js +33 -6
- package/dist/cjs/client/index.js +30 -2
- package/dist/cjs/client/mark.js +1 -1
- package/dist/cjs/client/provider.js +60 -8
- package/dist/cjs/client/use-active-path.js +77 -0
- package/dist/cjs/client/use-checkout.js +3 -3
- package/dist/cjs/errors.js +4 -4
- package/dist/cjs/helpers.js +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/next/index.js +96 -15
- package/dist/cjs/server/client.js +22 -14
- package/dist/cjs/server/handler.js +99 -16
- package/dist/cjs/server/http.js +1 -1
- package/dist/cjs/server/index.js +8 -1
- package/dist/cjs/server/resources/access.js +3 -3
- package/dist/cjs/server/resources/ai.js +1 -1
- package/dist/cjs/server/resources/analytics.js +66 -0
- package/dist/cjs/server/resources/apps.js +4 -4
- package/dist/cjs/server/resources/billing.js +5 -5
- package/dist/cjs/server/resources/drive.js +4 -4
- package/dist/cjs/server/resources/entitlements.js +7 -7
- package/dist/cjs/server/resources/identity.js +4 -4
- package/dist/cjs/server/resources/integrations.js +66 -0
- package/dist/cjs/server/resources/logs.js +3 -3
- package/dist/cjs/server/resources/sessions.js +6 -6
- package/dist/cjs/server/webhooks.js +127 -0
- package/dist/cjs/types.js +1 -1
- package/dist/esm/cli/doctor.js +3 -3
- package/dist/esm/cli/init.js +3 -3
- package/dist/esm/client/components/access-gate.js +158 -0
- package/dist/esm/client/components/account-sessions.js +24 -22
- package/dist/esm/client/components/app-shell.js +154 -0
- package/dist/esm/client/components/apps-grid.js +4 -3
- package/dist/esm/client/components/feature-gate.js +2 -2
- package/dist/esm/client/components/integrations-modal.js +92 -0
- package/dist/esm/client/components/logo.js +3 -3
- package/dist/esm/client/components/motion.js +21 -0
- package/dist/esm/client/components/nav.js +89 -0
- package/dist/esm/client/components/plan-pricing.js +1 -1
- package/dist/esm/client/components/plans.js +42 -40
- package/dist/esm/client/components/readiness.js +6 -4
- package/dist/esm/client/components/side-nav.js +53 -0
- package/dist/esm/client/components/subscribe-dialog.js +2 -2
- package/dist/esm/client/components/subscription.js +3 -2
- package/dist/esm/client/components/surfaces.js +51 -0
- package/dist/esm/client/components/usage-meter.js +4 -3
- package/dist/esm/client/components/user-menu.js +34 -7
- package/dist/esm/client/index.js +16 -2
- package/dist/esm/client/mark.js +1 -1
- package/dist/esm/client/provider.js +59 -8
- package/dist/esm/client/use-active-path.js +74 -0
- package/dist/esm/client/use-checkout.js +3 -3
- package/dist/esm/errors.js +4 -4
- package/dist/esm/helpers.js +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/next/index.js +95 -15
- package/dist/esm/server/client.js +22 -14
- package/dist/esm/server/handler.js +99 -16
- package/dist/esm/server/http.js +1 -1
- package/dist/esm/server/index.js +5 -0
- package/dist/esm/server/resources/access.js +3 -3
- package/dist/esm/server/resources/ai.js +1 -1
- package/dist/esm/server/resources/analytics.js +62 -0
- package/dist/esm/server/resources/apps.js +4 -4
- package/dist/esm/server/resources/billing.js +5 -5
- package/dist/esm/server/resources/drive.js +4 -4
- package/dist/esm/server/resources/entitlements.js +7 -7
- package/dist/esm/server/resources/identity.js +4 -4
- package/dist/esm/server/resources/integrations.js +62 -0
- package/dist/esm/server/resources/logs.js +3 -3
- package/dist/esm/server/resources/sessions.js +6 -6
- package/dist/esm/server/webhooks.js +123 -0
- package/dist/esm/types.js +1 -1
- package/dist/theme.css +28 -0
- package/dist/types/client/components/access-gate.d.ts +79 -0
- package/dist/types/client/components/account-sessions.d.ts +1 -1
- package/dist/types/client/components/app-shell.d.ts +115 -0
- package/dist/types/client/components/apps-grid.d.ts +1 -1
- package/dist/types/client/components/feature-gate.d.ts +1 -1
- package/dist/types/client/components/integrations-modal.d.ts +21 -0
- package/dist/types/client/components/logo.d.ts +5 -5
- 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/plan-pricing.d.ts +2 -2
- package/dist/types/client/components/readiness.d.ts +2 -2
- package/dist/types/client/components/side-nav.d.ts +31 -0
- package/dist/types/client/components/subscribe-dialog.d.ts +3 -3
- package/dist/types/client/components/surfaces.d.ts +51 -0
- package/dist/types/client/components/usage-meter.d.ts +1 -1
- package/dist/types/client/components/user-menu.d.ts +2 -2
- package/dist/types/client/index.d.ts +13 -2
- package/dist/types/client/mark.d.ts +1 -1
- package/dist/types/client/provider.d.ts +44 -9
- package/dist/types/client/use-active-path.d.ts +8 -0
- package/dist/types/client/use-checkout.d.ts +3 -3
- package/dist/types/errors.d.ts +4 -4
- package/dist/types/helpers.d.ts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/next/index.d.ts +40 -0
- package/dist/types/server/client.d.ts +17 -11
- package/dist/types/server/handler.d.ts +2 -2
- package/dist/types/server/index.d.ts +7 -0
- package/dist/types/server/resources/access.d.ts +3 -3
- package/dist/types/server/resources/ai.d.ts +2 -2
- package/dist/types/server/resources/analytics.d.ts +31 -0
- package/dist/types/server/resources/apps.d.ts +4 -4
- package/dist/types/server/resources/billing.d.ts +4 -4
- package/dist/types/server/resources/drive.d.ts +4 -4
- package/dist/types/server/resources/entitlements.d.ts +4 -4
- package/dist/types/server/resources/identity.d.ts +14 -4
- package/dist/types/server/resources/integrations.d.ts +40 -0
- package/dist/types/server/resources/logs.d.ts +3 -3
- package/dist/types/server/resources/sessions.d.ts +5 -5
- package/dist/types/server/webhooks.d.ts +67 -0
- package/dist/types/types.d.ts +220 -39
- package/package.json +10 -4
package/dist/esm/next/index.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
|
+
import { cache } from "react";
|
|
1
2
|
import { ConnectClient } from "../server/client.js";
|
|
2
3
|
import { createConnectHandler } from "../server/handler.js";
|
|
3
4
|
/**
|
|
4
5
|
* Plug-and-play wiring for Next.js.
|
|
5
6
|
*
|
|
6
7
|
* Everything below is server-side. Nothing here is *statically* imported from
|
|
7
|
-
* `next
|
|
8
|
-
* and the edge runtime alike
|
|
9
|
-
* that does reach for `next/headers`
|
|
10
|
-
*
|
|
8
|
+
* `next` itself, so this module loads in App Router route handlers, the Node
|
|
9
|
+
* runtime and the edge runtime alike without a `next` peer dependency. The
|
|
10
|
+
* one place that does reach for `next/headers` (or, in `requireConnectSession`,
|
|
11
|
+
* `next/navigation`) does it behind a dynamic import, inside the function that
|
|
12
|
+
* needs it, and says so when it isn't there. `react` is a real, static
|
|
13
|
+
* dependency though - `getConnectSession`'s automatic per-request memoization
|
|
14
|
+
* (below) is built on its `cache()`, which every App Router project already
|
|
15
|
+
* has.
|
|
11
16
|
*
|
|
12
17
|
* The whole integration is two files:
|
|
13
18
|
*
|
|
@@ -33,7 +38,7 @@ import { createConnectHandler } from "../server/handler.js";
|
|
|
33
38
|
* | `CONNECT_SESSION_COOKIE` | no | session cookie name |
|
|
34
39
|
*
|
|
35
40
|
* Register `{your origin}/api/connect/callback` as a redirect URI in the
|
|
36
|
-
*
|
|
41
|
+
* application's Integration tab. That is the only step this cannot do for you.
|
|
37
42
|
*
|
|
38
43
|
* No `next.config.ts` changes needed either, including `transpilePackages`:
|
|
39
44
|
* `dist/` is already ES2022 with `"use client"` hoisted to the top of every
|
|
@@ -198,6 +203,35 @@ async function requestFromHeaders() {
|
|
|
198
203
|
const proto = incoming.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
|
|
199
204
|
return new Request(`${proto}://${host}/`, { headers: incoming });
|
|
200
205
|
}
|
|
206
|
+
async function readSession(request, options) {
|
|
207
|
+
const store = options.session ?? cookieSessionStore();
|
|
208
|
+
const token = await store.read(request);
|
|
209
|
+
if (!token)
|
|
210
|
+
return null;
|
|
211
|
+
try {
|
|
212
|
+
return await (options.client ?? connectClient()).identity.getUser(token);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* The ambient case - no explicit `request`, default client, default cookie
|
|
220
|
+
* store - wrapped in React's `cache()` so every call in the same request
|
|
221
|
+
* shares one round trip to Connect. A layout reading the session for a nav
|
|
222
|
+
* avatar and the page beneath it reading it again for a role check hit this
|
|
223
|
+
* exact function; without the wrapper that's two live reads of the same
|
|
224
|
+
* answer.
|
|
225
|
+
*
|
|
226
|
+
* Deliberately its own zero-argument function rather than `cache()` wrapping
|
|
227
|
+
* `getConnectSession` directly - `cache()` keys on argument identity, and
|
|
228
|
+
* `getConnectSession`'s default `options = {}` is a fresh object on every
|
|
229
|
+
* call, so wrapping it as-is would never hit the cache for the very call
|
|
230
|
+
* pattern this exists to speed up.
|
|
231
|
+
*/
|
|
232
|
+
const cachedAmbientSession = cache(async () => {
|
|
233
|
+
return readSession(await requestFromHeaders(), {});
|
|
234
|
+
});
|
|
201
235
|
/**
|
|
202
236
|
* The signed-in user for a Server Component, Route Handler or Server Action,
|
|
203
237
|
* or `null`.
|
|
@@ -208,6 +242,9 @@ async function requestFromHeaders() {
|
|
|
208
242
|
* if (!session) redirect("/api/connect/signin?next=/dashboard");
|
|
209
243
|
* ```
|
|
210
244
|
*
|
|
245
|
+
* `requireConnectSession()` below is that exact check in one call, for the
|
|
246
|
+
* common case of "redirect if signed out."
|
|
247
|
+
*
|
|
211
248
|
* Pass a `Request` to use it somewhere `next/headers` does not exist - a
|
|
212
249
|
* framework-agnostic handler, a test, a custom server. Both forms take the
|
|
213
250
|
* same path afterwards; the argument is only how the cookie is found.
|
|
@@ -219,18 +256,61 @@ async function requestFromHeaders() {
|
|
|
219
256
|
*
|
|
220
257
|
* Returns `null` for every failure - an expired session and a missing one both
|
|
221
258
|
* mean "not signed in", and a page guard should not need a try/catch.
|
|
259
|
+
*
|
|
260
|
+
* **Memoized per request for the plain, zero-argument call** - the shape
|
|
261
|
+
* every page, layout and Server Action actually uses - via React's `cache()`.
|
|
262
|
+
* Passing an explicit `request`, `client` or `session` bypasses the cache
|
|
263
|
+
* and always makes a live call: those are for tests and non-ambient
|
|
264
|
+
* callers, where a fresh read every time is the point, not an oversight.
|
|
222
265
|
*/
|
|
223
266
|
export async function getConnectSession(request, options = {}) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
267
|
+
if (!request && !options.client && !options.session)
|
|
268
|
+
return cachedAmbientSession();
|
|
269
|
+
return readSession(request ?? (await requestFromHeaders()), options);
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* `getConnectSession()`, minus the boilerplate of checking it and redirecting
|
|
273
|
+
* yourself:
|
|
274
|
+
*
|
|
275
|
+
* ```tsx
|
|
276
|
+
* // app/dashboard/page.tsx
|
|
277
|
+
* const session = await requireConnectSession();
|
|
278
|
+
* ```
|
|
279
|
+
*
|
|
280
|
+
* Redirects to `/signin` under the same `basePath` as `getConnectSession`'s
|
|
281
|
+
* default (`/api/connect`) when signed out - pass `signInPath` if you mounted
|
|
282
|
+
* the bridge somewhere else, and `next` to send the visitor back to where
|
|
283
|
+
* they were once they're signed in. Only sensible in a Server Component,
|
|
284
|
+
* Route Handler or Server Action, the same three places `redirect()` from
|
|
285
|
+
* `next/navigation` itself works.
|
|
286
|
+
*
|
|
287
|
+
* A thin wrapper, not a framework of its own - reach for `getConnectSession`
|
|
288
|
+
* plus your own check when "not signed in" isn't "redirect to sign-in"
|
|
289
|
+
* (an API route returning 401, say). Role and entitlement checks stay
|
|
290
|
+
* outside this function on purpose: `session.applicationRole.granted` and
|
|
291
|
+
* `connect.entitlements.*` are one line each on top of the session this
|
|
292
|
+
* already gives you, and where that line redirects (a plans page, a
|
|
293
|
+
* "forbidden" screen) is a decision only your app can make - see
|
|
294
|
+
* "Authentication & authorization" for the pattern.
|
|
295
|
+
*/
|
|
296
|
+
export async function requireConnectSession(request, options = {}) {
|
|
297
|
+
const session = await getConnectSession(request, options);
|
|
298
|
+
if (session)
|
|
299
|
+
return session;
|
|
300
|
+
const signIn = options.signInPath ?? "/api/connect/signin";
|
|
301
|
+
return redirectTo(options.next ? `${signIn}?next=${encodeURIComponent(options.next)}` : signIn);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* `next/navigation`'s `redirect()`, imported the same dynamic way as
|
|
305
|
+
* `next/headers` above and for the same reason: no static dependency on
|
|
306
|
+
* `next`'s own types at this package's build time. Typed by hand rather than
|
|
307
|
+
* imported, since that's the only way to erase them - `redirect()` itself is
|
|
308
|
+
* typed `never`, which is what lets `requireConnectSession` return here
|
|
309
|
+
* without its own `return` after the call.
|
|
310
|
+
*/
|
|
311
|
+
async function redirectTo(url) {
|
|
312
|
+
const { redirect } = (await import("next/navigation"));
|
|
313
|
+
return redirect(url);
|
|
234
314
|
}
|
|
235
315
|
export { createConnectHandler } from "../server/handler.js";
|
|
236
316
|
export { ConnectClient } from "../server/client.js";
|
|
@@ -5,11 +5,13 @@ import { BillingResource } from "./resources/billing.js";
|
|
|
5
5
|
import { DriveResource } from "./resources/drive.js";
|
|
6
6
|
import { EntitlementsResource } from "./resources/entitlements.js";
|
|
7
7
|
import { IdentityResource } from "./resources/identity.js";
|
|
8
|
+
import { IntegrationsResource } from "./resources/integrations.js";
|
|
8
9
|
import { LogsResource } from "./resources/logs.js";
|
|
10
|
+
import { AnalyticsResource } from "./resources/analytics.js";
|
|
9
11
|
import { OtpResource } from "./resources/otp.js";
|
|
10
12
|
import { SessionsResource } from "./resources/sessions.js";
|
|
11
13
|
export const DEFAULT_BASE_URL = "https://connect.cosmoops.com";
|
|
12
|
-
const
|
|
14
|
+
const APPLICATION_API_PREFIX = "/api/v1/application";
|
|
13
15
|
/**
|
|
14
16
|
* Refuses to build a client in a browser.
|
|
15
17
|
*
|
|
@@ -38,9 +40,9 @@ function assertNotBrowser() {
|
|
|
38
40
|
"in browser code instead; it talks to your own origin and holds no credential.");
|
|
39
41
|
}
|
|
40
42
|
/**
|
|
41
|
-
* The CosmoOps Connect
|
|
43
|
+
* The CosmoOps Connect application-partner API.
|
|
42
44
|
*
|
|
43
|
-
* Covers `/api/v1/
|
|
45
|
+
* Covers `/api/v1/application/*` and nothing else - the surface an application platform
|
|
44
46
|
* integrates against. Connect's internal API is not part of this package and
|
|
45
47
|
* never will be.
|
|
46
48
|
*
|
|
@@ -51,7 +53,7 @@ function assertNotBrowser() {
|
|
|
51
53
|
* });
|
|
52
54
|
*
|
|
53
55
|
* const session = await connect.identity.exchangeCode({ code, redirectUri });
|
|
54
|
-
* const { ready } = await connect.entitlements.getReadiness(session.user.
|
|
56
|
+
* const { ready } = await connect.entitlements.getReadiness(session.user.workspaceId);
|
|
55
57
|
* ```
|
|
56
58
|
*
|
|
57
59
|
* Every failure throws a `ConnectError` carrying a stable `code`. Nothing here
|
|
@@ -60,32 +62,36 @@ function assertNotBrowser() {
|
|
|
60
62
|
export class ConnectClient {
|
|
61
63
|
/** "Login with Connect" - the OAuth2 handshake and the session after it. */
|
|
62
64
|
identity;
|
|
63
|
-
/** What a signed-in member may do on your
|
|
65
|
+
/** What a signed-in member may do on your application. */
|
|
64
66
|
access;
|
|
65
|
-
/** The
|
|
67
|
+
/** The applications a workspace can open - for a launcher grid. */
|
|
66
68
|
apps;
|
|
67
|
-
/** Whether a workspace may use your
|
|
69
|
+
/** Whether a workspace may use your application, and on what plan. */
|
|
68
70
|
entitlements;
|
|
69
71
|
/** Your plan catalog, and metering usage against add-on balances. */
|
|
70
72
|
billing;
|
|
71
|
-
/** Files in
|
|
73
|
+
/** Files in a workspace's Drive. */
|
|
72
74
|
drive;
|
|
73
75
|
/** RAG chat and structured extraction. */
|
|
74
76
|
ai;
|
|
75
77
|
/** WhatsApp/SMS phone verification. */
|
|
76
78
|
otp;
|
|
77
|
-
/** Feature events into the Console's audit log, filterable to your
|
|
79
|
+
/** Feature events into the Console's audit log, filterable to your application. */
|
|
78
80
|
logs;
|
|
79
|
-
/**
|
|
81
|
+
/** Emit product events and read back this application's aggregated numbers. */
|
|
82
|
+
analytics;
|
|
83
|
+
/** This Connect account's own login sessions - account-wide, not scoped to this application. Requires the `sessions:manage` scope. */
|
|
80
84
|
sessions;
|
|
85
|
+
/** Composio-hosted third-party connections (Google Calendar, Slack, ...) a workspace has connected. */
|
|
86
|
+
integrations;
|
|
81
87
|
constructor(options) {
|
|
82
88
|
assertNotBrowser();
|
|
83
89
|
if (!options.apiKey) {
|
|
84
|
-
throw new TypeError("ConnectClient requires `apiKey` - the plaintext shown once in the
|
|
90
|
+
throw new TypeError("ConnectClient requires `apiKey` - the plaintext shown once in the Integration tab.");
|
|
85
91
|
}
|
|
86
92
|
const appBaseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
87
93
|
const transport = {
|
|
88
|
-
baseUrl: `${appBaseUrl}${
|
|
94
|
+
baseUrl: `${appBaseUrl}${APPLICATION_API_PREFIX}`,
|
|
89
95
|
apiKey: options.apiKey,
|
|
90
96
|
timeoutMs: options.timeoutMs ?? 20_000,
|
|
91
97
|
maxRetries: options.maxRetries ?? 2,
|
|
@@ -93,7 +99,7 @@ export class ConnectClient {
|
|
|
93
99
|
userAgent: `cosmoops-connect-sdk/${SDK_VERSION}`,
|
|
94
100
|
};
|
|
95
101
|
// `authorizeUrl` points at the app, not the API prefix - it is a browser
|
|
96
|
-
// redirect, not
|
|
102
|
+
// redirect, not an application call, so identity takes both.
|
|
97
103
|
this.identity = new IdentityResource(transport, options.clientId ?? null, appBaseUrl);
|
|
98
104
|
this.access = new AccessResource(transport);
|
|
99
105
|
this.apps = new AppsResource(transport);
|
|
@@ -103,8 +109,10 @@ export class ConnectClient {
|
|
|
103
109
|
this.ai = new AiResource(transport);
|
|
104
110
|
this.otp = new OtpResource(transport);
|
|
105
111
|
this.logs = new LogsResource(transport);
|
|
112
|
+
this.analytics = new AnalyticsResource(transport);
|
|
106
113
|
this.sessions = new SessionsResource(transport, options.clientId ?? null);
|
|
114
|
+
this.integrations = new IntegrationsResource(transport);
|
|
107
115
|
}
|
|
108
116
|
}
|
|
109
117
|
/** Kept in step with `package.json` by `sdk.test.ts`. */
|
|
110
|
-
export const SDK_VERSION = "0.
|
|
118
|
+
export const SDK_VERSION = "0.7.0";
|
|
@@ -91,9 +91,9 @@ function redirect(location, cookies = []) {
|
|
|
91
91
|
* export { handler as GET, handler as POST };
|
|
92
92
|
* ```
|
|
93
93
|
*
|
|
94
|
-
* Every route resolves the
|
|
94
|
+
* Every route resolves the workspace from the caller's *own* session, never
|
|
95
95
|
* from a query parameter. A browser must not be able to ask about an
|
|
96
|
-
*
|
|
96
|
+
* workspace it is not signed into, and a client-supplied `workspaceId`
|
|
97
97
|
* is exactly that hole.
|
|
98
98
|
*/
|
|
99
99
|
export function createConnectHandler(options) {
|
|
@@ -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) {
|
|
@@ -133,7 +160,7 @@ export function createConnectHandler(options) {
|
|
|
133
160
|
}
|
|
134
161
|
return token;
|
|
135
162
|
}
|
|
136
|
-
/** Signed-in identity plus the
|
|
163
|
+
/** Signed-in identity plus the workspace it belongs to - the first read every other route needs. */
|
|
137
164
|
async function whoami(request) {
|
|
138
165
|
return client.identity.getUser(await requireToken(request));
|
|
139
166
|
}
|
|
@@ -143,30 +170,61 @@ export function createConnectHandler(options) {
|
|
|
143
170
|
// Signed out is a normal state for a page rendering a Login button, not
|
|
144
171
|
// an error - `user: null` rather than a 401 the client must catch.
|
|
145
172
|
if (!token)
|
|
146
|
-
return json({ user: null, subscription: null,
|
|
173
|
+
return json({ user: null, subscription: null, applicationRole: null });
|
|
147
174
|
return json(await client.identity.getUser(token));
|
|
148
175
|
},
|
|
149
176
|
"GET /entitlements": async (request) => {
|
|
150
177
|
const { user } = await whoami(request);
|
|
151
178
|
const [readiness, subscription] = await Promise.all([
|
|
152
|
-
client.entitlements.getReadiness(user.
|
|
153
|
-
client.entitlements.getSubscription(user.
|
|
179
|
+
client.entitlements.getReadiness(user.workspaceId),
|
|
180
|
+
client.entitlements.getSubscription(user.workspaceId),
|
|
154
181
|
]);
|
|
155
182
|
return json({ readiness, subscription });
|
|
156
183
|
},
|
|
157
184
|
"GET /plans": async () => json(await client.billing.listPlans()),
|
|
158
|
-
//
|
|
185
|
+
// Workspace comes from the caller's own session, same as every other
|
|
159
186
|
// route here - a browser must not be able to ask what another workspace
|
|
160
187
|
// has left.
|
|
161
188
|
"GET /addons": async (request) => {
|
|
162
189
|
const { user } = await whoami(request);
|
|
163
|
-
return json(await client.billing.getAddonUsage(user.
|
|
190
|
+
return json(await client.billing.getAddonUsage(user.workspaceId));
|
|
164
191
|
},
|
|
165
192
|
"GET /apps": async (request) => {
|
|
166
193
|
const { user } = await whoami(request);
|
|
167
|
-
return json({ apps: await client.apps.list(user.
|
|
194
|
+
return json({ apps: await client.apps.list(user.workspaceId) });
|
|
168
195
|
},
|
|
169
|
-
|
|
196
|
+
"GET /integrations": async (request) => {
|
|
197
|
+
const { user } = await whoami(request);
|
|
198
|
+
return json({ toolkits: await client.integrations.list(user.workspaceId) });
|
|
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.workspaceId, 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.workspaceId, body.toolkitSlug);
|
|
225
|
+
return json({ ok: true });
|
|
226
|
+
},
|
|
227
|
+
// Account-wide, not scoped to this application - 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
|
|
172
230
|
// URL, so the exact-match routes table above never needs a dynamic
|
|
@@ -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/http.js
CHANGED
|
@@ -40,7 +40,7 @@ function parseRetryAfterMs(response) {
|
|
|
40
40
|
return Number.isFinite(seconds) ? Math.max(0, seconds) * 1000 : null;
|
|
41
41
|
}
|
|
42
42
|
/**
|
|
43
|
-
* Both error dialects, one shape. `/api/v1/
|
|
43
|
+
* Both error dialects, one shape. `/api/v1/application/*` answers
|
|
44
44
|
* `{ error: { code, message } }`; the `/sso/*` endpoints answer OAuth2's
|
|
45
45
|
* `{ error, error_description }`.
|
|
46
46
|
*/
|
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";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { pathSegment, request } from "../http.js";
|
|
2
|
-
/** What a signed-in member may do on your
|
|
2
|
+
/** What a signed-in member may do on your application, and the role catalog behind it. */
|
|
3
3
|
export class AccessResource {
|
|
4
4
|
transport;
|
|
5
5
|
constructor(transport) {
|
|
@@ -14,10 +14,10 @@ export class AccessResource {
|
|
|
14
14
|
return request(this.transport, {
|
|
15
15
|
method: "GET",
|
|
16
16
|
path: `/rbac/users/${pathSegment(userId, "userId")}/roles`,
|
|
17
|
-
query: {
|
|
17
|
+
query: { workspaceId: input.workspaceId },
|
|
18
18
|
});
|
|
19
19
|
}
|
|
20
|
-
/** Your
|
|
20
|
+
/** Your application's own permission catalog and role definitions - the definitions `getUserRoles` resolves against. */
|
|
21
21
|
listRoles() {
|
|
22
22
|
return request(this.transport, { method: "GET", path: "/roles" });
|
|
23
23
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { request } from "../http.js";
|
|
2
|
+
/**
|
|
3
|
+
* Your application'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 an application 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
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { request } from "../http.js";
|
|
2
|
-
/** The
|
|
2
|
+
/** The applications a workspace can open - the data behind a launcher grid. */
|
|
3
3
|
export class AppsResource {
|
|
4
4
|
transport;
|
|
5
5
|
constructor(transport) {
|
|
@@ -7,13 +7,13 @@ export class AppsResource {
|
|
|
7
7
|
}
|
|
8
8
|
/**
|
|
9
9
|
* Launchable apps only, so a grid tile is never a dead end. The calling
|
|
10
|
-
*
|
|
10
|
+
* application is included and flagged `isCurrent` rather than filtered out.
|
|
11
11
|
*/
|
|
12
|
-
async list(
|
|
12
|
+
async list(workspaceId) {
|
|
13
13
|
const result = await request(this.transport, {
|
|
14
14
|
method: "GET",
|
|
15
15
|
path: "/apps",
|
|
16
|
-
query: {
|
|
16
|
+
query: { workspaceId },
|
|
17
17
|
});
|
|
18
18
|
// `?? []` for the same reason `billing.listPlans` has it: this is a
|
|
19
19
|
// collection every caller immediately maps or measures, and a body without
|
|
@@ -7,7 +7,7 @@ export class BillingResource {
|
|
|
7
7
|
this.transport = transport;
|
|
8
8
|
}
|
|
9
9
|
/**
|
|
10
|
-
* Your
|
|
10
|
+
* Your application's active plans - render live pricing instead of hardcoding it.
|
|
11
11
|
*
|
|
12
12
|
* `features` is normalised on the way out: Connect used to send bare keys
|
|
13
13
|
* and now sends `{ key, name, description }`, and this SDK is versioned
|
|
@@ -25,7 +25,7 @@ export class BillingResource {
|
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
28
|
-
* What is left of every consumable add-on on
|
|
28
|
+
* What is left of every consumable add-on on a workspace's
|
|
29
29
|
* subscription - the read half of `consumeAddon`.
|
|
30
30
|
*
|
|
31
31
|
* Needs a key scoped to `addons:read`, which is separate from
|
|
@@ -35,10 +35,10 @@ export class BillingResource {
|
|
|
35
35
|
* Returns an empty `addons` array rather than throwing when there is
|
|
36
36
|
* nothing to meter, so a caller can render it unconditionally.
|
|
37
37
|
*/
|
|
38
|
-
async getAddonUsage(
|
|
38
|
+
async getAddonUsage(workspaceId) {
|
|
39
39
|
const response = await request(this.transport, {
|
|
40
40
|
method: "GET",
|
|
41
|
-
path: `/addons/
|
|
41
|
+
path: `/addons/workspaces/${pathSegment(workspaceId, "workspaceId")}`,
|
|
42
42
|
});
|
|
43
43
|
// Normalised, not passed through: the promise above resolves for any 2xx,
|
|
44
44
|
// and `ConnectUsageMeter` reads `addons` straight off it. A body without
|
|
@@ -49,7 +49,7 @@ export class BillingResource {
|
|
|
49
49
|
}
|
|
50
50
|
/**
|
|
51
51
|
* Debit a consumable add-on's per-cycle balance on behalf of an
|
|
52
|
-
*
|
|
52
|
+
* workspace's users. Call it at whatever cadence your usage happens.
|
|
53
53
|
*
|
|
54
54
|
* An `Idempotency-Key` is always sent, generated per call when you do not
|
|
55
55
|
* supply one: a balance cannot be un-spent, so a retried consume without a
|