@cosmoops/connect-sdk 0.1.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/LICENSE +34 -0
- package/README.md +446 -0
- package/dist/cjs/cli/bin.js +16 -0
- package/dist/cjs/cli/init.js +179 -0
- package/dist/cjs/client/components/account-sessions.js +112 -0
- package/dist/cjs/client/components/apps-grid.js +46 -0
- package/dist/cjs/client/components/auth-buttons.js +42 -0
- package/dist/cjs/client/components/feature-gate.js +61 -0
- package/dist/cjs/client/components/link-button.js +24 -0
- package/dist/cjs/client/components/logo.js +27 -0
- package/dist/cjs/client/components/plan-pricing.js +166 -0
- package/dist/cjs/client/components/plans-page.js +39 -0
- package/dist/cjs/client/components/plans.js +212 -0
- package/dist/cjs/client/components/readiness.js +117 -0
- package/dist/cjs/client/components/subscribe-dialog.js +60 -0
- package/dist/cjs/client/components/subscription.js +189 -0
- package/dist/cjs/client/components/usage-meter.js +68 -0
- package/dist/cjs/client/components/user-menu.js +172 -0
- package/dist/cjs/client/index.js +99 -0
- package/dist/cjs/client/mark.js +16 -0
- package/dist/cjs/client/provider.js +259 -0
- package/dist/cjs/client/use-checkout.js +173 -0
- package/dist/cjs/errors.js +74 -0
- package/dist/cjs/format-relative-time.js +18 -0
- package/dist/cjs/helpers.js +30 -0
- package/dist/cjs/index.js +22 -0
- package/dist/cjs/next/index.js +311 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/server/client.js +114 -0
- package/dist/cjs/server/handler.js +333 -0
- package/dist/cjs/server/http.js +160 -0
- package/dist/cjs/server/index.js +18 -0
- package/dist/cjs/server/resources/access.js +28 -0
- package/dist/cjs/server/resources/ai.js +36 -0
- package/dist/cjs/server/resources/apps.js +30 -0
- package/dist/cjs/server/resources/billing.js +71 -0
- package/dist/cjs/server/resources/drive.js +39 -0
- package/dist/cjs/server/resources/entitlements.js +49 -0
- package/dist/cjs/server/resources/identity.js +194 -0
- package/dist/cjs/server/resources/logs.js +41 -0
- package/dist/cjs/server/resources/otp.js +29 -0
- package/dist/cjs/server/resources/sessions.js +82 -0
- package/dist/cjs/types.js +15 -0
- package/dist/esm/cli/bin.js +13 -0
- package/dist/esm/cli/init.js +174 -0
- package/dist/esm/client/components/account-sessions.js +109 -0
- package/dist/esm/client/components/apps-grid.js +43 -0
- package/dist/esm/client/components/auth-buttons.js +38 -0
- package/dist/esm/client/components/feature-gate.js +57 -0
- package/dist/esm/client/components/link-button.js +21 -0
- package/dist/esm/client/components/logo.js +24 -0
- package/dist/esm/client/components/plan-pricing.js +156 -0
- package/dist/esm/client/components/plans-page.js +36 -0
- package/dist/esm/client/components/plans.js +209 -0
- package/dist/esm/client/components/readiness.js +113 -0
- package/dist/esm/client/components/subscribe-dialog.js +57 -0
- package/dist/esm/client/components/subscription.js +181 -0
- package/dist/esm/client/components/usage-meter.js +65 -0
- package/dist/esm/client/components/user-menu.js +164 -0
- package/dist/esm/client/index.js +47 -0
- package/dist/esm/client/mark.js +13 -0
- package/dist/esm/client/provider.js +247 -0
- package/dist/esm/client/use-checkout.js +168 -0
- package/dist/esm/errors.js +69 -0
- package/dist/esm/format-relative-time.js +15 -0
- package/dist/esm/helpers.js +26 -0
- package/dist/esm/index.js +15 -0
- package/dist/esm/next/index.js +279 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/server/client.js +110 -0
- package/dist/esm/server/handler.js +330 -0
- package/dist/esm/server/http.js +156 -0
- package/dist/esm/server/index.js +10 -0
- package/dist/esm/server/resources/access.js +24 -0
- package/dist/esm/server/resources/ai.js +32 -0
- package/dist/esm/server/resources/apps.js +26 -0
- package/dist/esm/server/resources/billing.js +67 -0
- package/dist/esm/server/resources/drive.js +35 -0
- package/dist/esm/server/resources/entitlements.js +45 -0
- package/dist/esm/server/resources/identity.js +189 -0
- package/dist/esm/server/resources/logs.js +37 -0
- package/dist/esm/server/resources/otp.js +25 -0
- package/dist/esm/server/resources/sessions.js +78 -0
- package/dist/esm/types.js +14 -0
- package/dist/theme.css +84 -0
- package/dist/types/cli/bin.d.ts +1 -0
- package/dist/types/cli/init.d.ts +34 -0
- package/dist/types/client/components/account-sessions.d.ts +25 -0
- package/dist/types/client/components/apps-grid.d.ts +20 -0
- package/dist/types/client/components/auth-buttons.d.ts +31 -0
- package/dist/types/client/components/feature-gate.d.ts +57 -0
- package/dist/types/client/components/link-button.d.ts +20 -0
- package/dist/types/client/components/logo.d.ts +35 -0
- package/dist/types/client/components/plan-pricing.d.ts +96 -0
- package/dist/types/client/components/plans-page.d.ts +36 -0
- package/dist/types/client/components/plans.d.ts +34 -0
- package/dist/types/client/components/readiness.d.ts +52 -0
- package/dist/types/client/components/subscribe-dialog.d.ts +34 -0
- package/dist/types/client/components/subscription.d.ts +123 -0
- package/dist/types/client/components/usage-meter.d.ts +24 -0
- package/dist/types/client/components/user-menu.d.ts +86 -0
- package/dist/types/client/index.d.ts +47 -0
- package/dist/types/client/mark.d.ts +11 -0
- package/dist/types/client/provider.d.ts +209 -0
- package/dist/types/client/use-checkout.d.ts +93 -0
- package/dist/types/errors.d.ts +62 -0
- package/dist/types/format-relative-time.d.ts +2 -0
- package/dist/types/helpers.d.ts +33 -0
- package/dist/types/index.d.ts +17 -0
- package/dist/types/next/index.d.ts +136 -0
- package/dist/types/server/client.d.ts +83 -0
- package/dist/types/server/handler.d.ts +67 -0
- package/dist/types/server/http.d.ts +52 -0
- package/dist/types/server/index.d.ts +15 -0
- package/dist/types/server/resources/access.d.ts +17 -0
- package/dist/types/server/resources/ai.d.ts +24 -0
- package/dist/types/server/resources/apps.d.ts +12 -0
- package/dist/types/server/resources/billing.d.ts +41 -0
- package/dist/types/server/resources/drive.d.ts +26 -0
- package/dist/types/server/resources/entitlements.d.ts +31 -0
- package/dist/types/server/resources/identity.d.ts +76 -0
- package/dist/types/server/resources/logs.d.ts +20 -0
- package/dist/types/server/resources/otp.d.ts +17 -0
- package/dist/types/server/resources/sessions.d.ts +37 -0
- package/dist/types/types.d.ts +434 -0
- package/package.json +102 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { AccessResource } from "./resources/access.js";
|
|
2
|
+
import { AppsResource } from "./resources/apps.js";
|
|
3
|
+
import { AiResource } from "./resources/ai.js";
|
|
4
|
+
import { BillingResource } from "./resources/billing.js";
|
|
5
|
+
import { DriveResource } from "./resources/drive.js";
|
|
6
|
+
import { EntitlementsResource } from "./resources/entitlements.js";
|
|
7
|
+
import { IdentityResource } from "./resources/identity.js";
|
|
8
|
+
import { LogsResource } from "./resources/logs.js";
|
|
9
|
+
import { OtpResource } from "./resources/otp.js";
|
|
10
|
+
import { SessionsResource } from "./resources/sessions.js";
|
|
11
|
+
export const DEFAULT_BASE_URL = "https://connect.cosmoops.com";
|
|
12
|
+
const SERVICE_API_PREFIX = "/api/v1/service";
|
|
13
|
+
/**
|
|
14
|
+
* Refuses to build a client in a browser.
|
|
15
|
+
*
|
|
16
|
+
* This class holds the API key, which is also the OAuth `client_secret` and the
|
|
17
|
+
* `id_token` HMAC key - one credential with three jobs, so a copy that reaches
|
|
18
|
+
* a browser bundle is a full compromise of the integration, not a leaked read
|
|
19
|
+
* token. The entry-point split (`/server` versus `/client`) is what is supposed
|
|
20
|
+
* to prevent that, but an import is one keystroke and a bundler will happily
|
|
21
|
+
* follow it. This is the runtime backstop for when it does.
|
|
22
|
+
*
|
|
23
|
+
* The test is deliberately narrower than `typeof window !== "undefined"`.
|
|
24
|
+
* jsdom and happy-dom put a `window` on an ordinary Node process, and a test
|
|
25
|
+
* suite running server code under a DOM environment is not a leak - failing
|
|
26
|
+
* there would only teach people to work around this. A bundler may also shim
|
|
27
|
+
* `process` for the browser, but it never has `process.versions.node`, so that
|
|
28
|
+
* is what actually separates the two.
|
|
29
|
+
*/
|
|
30
|
+
function assertNotBrowser() {
|
|
31
|
+
if (typeof window === "undefined" || typeof window.document === "undefined")
|
|
32
|
+
return;
|
|
33
|
+
const versions = globalThis.process?.versions;
|
|
34
|
+
if (typeof versions?.node === "string")
|
|
35
|
+
return;
|
|
36
|
+
throw new TypeError("ConnectClient was constructed in a browser. It holds your API key - which is also your OAuth client_secret " +
|
|
37
|
+
"and id_token signing key - so it must only ever run on your server. Import `@cosmoops/connect-sdk/client` " +
|
|
38
|
+
"in browser code instead; it talks to your own origin and holds no credential.");
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The CosmoOps Connect service-partner API.
|
|
42
|
+
*
|
|
43
|
+
* Covers `/api/v1/service/*` and nothing else - the surface a service platform
|
|
44
|
+
* integrates against. Connect's internal API is not part of this package and
|
|
45
|
+
* never will be.
|
|
46
|
+
*
|
|
47
|
+
* ```ts
|
|
48
|
+
* const connect = new ConnectClient({
|
|
49
|
+
* apiKey: process.env.CONNECT_API_KEY!,
|
|
50
|
+
* clientId: process.env.CONNECT_CLIENT_ID,
|
|
51
|
+
* });
|
|
52
|
+
*
|
|
53
|
+
* const session = await connect.identity.exchangeCode({ code, redirectUri });
|
|
54
|
+
* const { ready } = await connect.entitlements.getReadiness(session.user.organizationId);
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* Every failure throws a `ConnectError` carrying a stable `code`. Nothing here
|
|
58
|
+
* holds state between calls, so one client can be built once and shared.
|
|
59
|
+
*/
|
|
60
|
+
export class ConnectClient {
|
|
61
|
+
/** "Login with Connect" - the OAuth2 handshake and the session after it. */
|
|
62
|
+
identity;
|
|
63
|
+
/** What a signed-in member may do on your service. */
|
|
64
|
+
access;
|
|
65
|
+
/** The services an organization can open - for a launcher grid. */
|
|
66
|
+
apps;
|
|
67
|
+
/** Whether a workspace may use your service, and on what plan. */
|
|
68
|
+
entitlements;
|
|
69
|
+
/** Your plan catalog, and metering usage against add-on balances. */
|
|
70
|
+
billing;
|
|
71
|
+
/** Files in an organization's Drive. */
|
|
72
|
+
drive;
|
|
73
|
+
/** RAG chat and structured extraction. */
|
|
74
|
+
ai;
|
|
75
|
+
/** WhatsApp/SMS phone verification. */
|
|
76
|
+
otp;
|
|
77
|
+
/** Feature events into the Console's audit log, filterable to your service. */
|
|
78
|
+
logs;
|
|
79
|
+
/** This Connect account's own login sessions - account-wide, not scoped to this service. Requires the `sessions:manage` scope. */
|
|
80
|
+
sessions;
|
|
81
|
+
constructor(options) {
|
|
82
|
+
assertNotBrowser();
|
|
83
|
+
if (!options.apiKey) {
|
|
84
|
+
throw new TypeError("ConnectClient requires `apiKey` - the plaintext shown once in the Setup tab.");
|
|
85
|
+
}
|
|
86
|
+
const appBaseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
87
|
+
const transport = {
|
|
88
|
+
baseUrl: `${appBaseUrl}${SERVICE_API_PREFIX}`,
|
|
89
|
+
apiKey: options.apiKey,
|
|
90
|
+
timeoutMs: options.timeoutMs ?? 20_000,
|
|
91
|
+
maxRetries: options.maxRetries ?? 2,
|
|
92
|
+
fetch: options.fetch ?? ((input, init) => globalThis.fetch(input, init)),
|
|
93
|
+
userAgent: `cosmoops-connect-sdk/${SDK_VERSION}`,
|
|
94
|
+
};
|
|
95
|
+
// `authorizeUrl` points at the app, not the API prefix - it is a browser
|
|
96
|
+
// redirect, not a service call, so identity takes both.
|
|
97
|
+
this.identity = new IdentityResource(transport, options.clientId ?? null, appBaseUrl);
|
|
98
|
+
this.access = new AccessResource(transport);
|
|
99
|
+
this.apps = new AppsResource(transport);
|
|
100
|
+
this.entitlements = new EntitlementsResource(transport);
|
|
101
|
+
this.billing = new BillingResource(transport);
|
|
102
|
+
this.drive = new DriveResource(transport);
|
|
103
|
+
this.ai = new AiResource(transport);
|
|
104
|
+
this.otp = new OtpResource(transport);
|
|
105
|
+
this.logs = new LogsResource(transport);
|
|
106
|
+
this.sessions = new SessionsResource(transport, options.clientId ?? null);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** Kept in step with `package.json` by `sdk.test.ts`. */
|
|
110
|
+
export const SDK_VERSION = "0.1.0";
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { ConnectError } from "../errors.js";
|
|
2
|
+
import { createState } from "./resources/identity.js";
|
|
3
|
+
/**
|
|
4
|
+
* A sentinel origin to resolve a candidate destination against. Never reached -
|
|
5
|
+
* `.invalid` is reserved by RFC 2606 precisely so it cannot resolve.
|
|
6
|
+
*/
|
|
7
|
+
const SAME_ORIGIN_PROBE = "https://connect.invalid";
|
|
8
|
+
/**
|
|
9
|
+
* Only same-origin paths are accepted as a post-login destination.
|
|
10
|
+
*
|
|
11
|
+
* `next` arrives from a URL anyone can craft, and handing it to a redirect
|
|
12
|
+
* unchecked is an open redirect - the classic way a login flow is turned into
|
|
13
|
+
* a credible phishing hop.
|
|
14
|
+
*
|
|
15
|
+
* **This resolves the candidate with the URL parser instead of matching its
|
|
16
|
+
* prefix, and the difference is not cosmetic.** String checks judge the bytes
|
|
17
|
+
* we received; the browser judges what its *own* parser makes of them, and the
|
|
18
|
+
* two disagree. Per the WHATWG URL standard a browser strips every ASCII tab,
|
|
19
|
+
* LF and CR from a URL before resolving it - so `/\t/evil.test` passes any
|
|
20
|
+
* `startsWith("//")` test, and then leaves the browser as `//evil.test`, a
|
|
21
|
+
* protocol-relative jump to another origin. Backslashes are the same trap from
|
|
22
|
+
* the other side: the parser treats `\` as `/` in a special scheme, so
|
|
23
|
+
* `/\evil.test` is off-origin too.
|
|
24
|
+
*
|
|
25
|
+
* Resolving against a sentinel origin and comparing `origin` asks the one
|
|
26
|
+
* question that matters - "where will the browser actually end up" - and gets
|
|
27
|
+
* tab-stripping, backslash folding, dot-segment collapsing and scheme handling
|
|
28
|
+
* from the parser rather than from a list of prefixes we thought of. It also
|
|
29
|
+
* neutralises a CRLF in the destination: the parser drops those characters, so
|
|
30
|
+
* they can no longer reach a `Location` header and make the runtime reject the
|
|
31
|
+
* whole response.
|
|
32
|
+
*/
|
|
33
|
+
function safeRedirect(candidate, fallback) {
|
|
34
|
+
if (!candidate)
|
|
35
|
+
return fallback;
|
|
36
|
+
let resolved;
|
|
37
|
+
try {
|
|
38
|
+
resolved = new URL(candidate, SAME_ORIGIN_PROBE);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return fallback;
|
|
42
|
+
}
|
|
43
|
+
if (resolved.origin !== SAME_ORIGIN_PROBE)
|
|
44
|
+
return fallback;
|
|
45
|
+
// Rebuilt from the parsed parts, not echoed back, so what we redirect to is
|
|
46
|
+
// the same string the check was performed on.
|
|
47
|
+
return `${resolved.pathname}${resolved.search}${resolved.hash}`;
|
|
48
|
+
}
|
|
49
|
+
/** Timing-independent compare for the CSRF state - lengths differ, so check that first. */
|
|
50
|
+
function sameState(a, b) {
|
|
51
|
+
if (a.length !== b.length)
|
|
52
|
+
return false;
|
|
53
|
+
let diff = 0;
|
|
54
|
+
for (let i = 0; i < a.length; i++)
|
|
55
|
+
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
56
|
+
return diff === 0;
|
|
57
|
+
}
|
|
58
|
+
function readCookie(request, name) {
|
|
59
|
+
const header = request.headers.get("cookie");
|
|
60
|
+
if (!header)
|
|
61
|
+
return null;
|
|
62
|
+
for (const part of header.split(";")) {
|
|
63
|
+
const [key, ...rest] = part.trim().split("=");
|
|
64
|
+
if (key === name)
|
|
65
|
+
return decodeURIComponent(rest.join("="));
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
function redirect(location, cookies = []) {
|
|
70
|
+
const response = new Response(null, { status: 302, headers: { location } });
|
|
71
|
+
for (const cookie of cookies)
|
|
72
|
+
response.headers.append("set-cookie", cookie);
|
|
73
|
+
return response;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The browser half of this SDK cannot hold an API key - so it does not.
|
|
77
|
+
*
|
|
78
|
+
* `@cosmoops/connect-sdk/client` talks only to your own origin, and this
|
|
79
|
+
* handler is what answers. It lives in your backend, holds the key, and
|
|
80
|
+
* exposes a fixed, named set of reads plus sign-out and session revocation.
|
|
81
|
+
* Nothing here proxies arbitrary paths: a generic passthrough would hand the
|
|
82
|
+
* browser your whole API surface under your credentials, which is the same
|
|
83
|
+
* leak with extra steps.
|
|
84
|
+
*
|
|
85
|
+
* Framework-agnostic - `Request` in, `Response` out, so it mounts anywhere
|
|
86
|
+
* that shape exists.
|
|
87
|
+
*
|
|
88
|
+
* ```ts
|
|
89
|
+
* // app/api/connect/[...route]/route.ts
|
|
90
|
+
* const handler = createConnectHandler({ client, session });
|
|
91
|
+
* export { handler as GET, handler as POST };
|
|
92
|
+
* ```
|
|
93
|
+
*
|
|
94
|
+
* Every route resolves the organization from the caller's *own* session, never
|
|
95
|
+
* from a query parameter. A browser must not be able to ask about an
|
|
96
|
+
* organization it is not signed into, and a client-supplied `organizationId`
|
|
97
|
+
* is exactly that hole.
|
|
98
|
+
*/
|
|
99
|
+
export function createConnectHandler(options) {
|
|
100
|
+
const basePath = (options.basePath ?? "/api/connect").replace(/\/+$/, "");
|
|
101
|
+
const { client, session } = options;
|
|
102
|
+
const defaultRedirect = options.defaultRedirect ?? "/";
|
|
103
|
+
const stateCookieName = options.stateCookieName ?? "connect_oauth_state";
|
|
104
|
+
const callbackPath = `${basePath}/callback`;
|
|
105
|
+
/**
|
|
106
|
+
* Short-lived, httpOnly, and `SameSite=Lax` - `Strict` would not be sent on
|
|
107
|
+
* the cross-site redirect back from Connect, which is the one request that
|
|
108
|
+
* has to read it. Scoped to `basePath` so it is never attached to ordinary
|
|
109
|
+
* page loads.
|
|
110
|
+
*/
|
|
111
|
+
function stateCookie(value, secure) {
|
|
112
|
+
const cleared = value === "";
|
|
113
|
+
return [
|
|
114
|
+
`${stateCookieName}=${encodeURIComponent(value)}`,
|
|
115
|
+
`Path=${basePath}`,
|
|
116
|
+
"HttpOnly",
|
|
117
|
+
"SameSite=Lax",
|
|
118
|
+
secure ? "Secure" : "",
|
|
119
|
+
cleared ? "Max-Age=0" : "Max-Age=900",
|
|
120
|
+
]
|
|
121
|
+
.filter(Boolean)
|
|
122
|
+
.join("; ");
|
|
123
|
+
}
|
|
124
|
+
async function requireToken(request) {
|
|
125
|
+
const token = await session.read(request);
|
|
126
|
+
if (!token) {
|
|
127
|
+
throw new ConnectError({
|
|
128
|
+
status: 401,
|
|
129
|
+
code: "invalid_token",
|
|
130
|
+
message: "No Connect session",
|
|
131
|
+
endpoint: "bridge",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return token;
|
|
135
|
+
}
|
|
136
|
+
/** Signed-in identity plus the organization it belongs to - the first read every other route needs. */
|
|
137
|
+
async function whoami(request) {
|
|
138
|
+
return client.identity.getUser(await requireToken(request));
|
|
139
|
+
}
|
|
140
|
+
const routes = {
|
|
141
|
+
"GET /session": async (request) => {
|
|
142
|
+
const token = await session.read(request);
|
|
143
|
+
// Signed out is a normal state for a page rendering a Login button, not
|
|
144
|
+
// an error - `user: null` rather than a 401 the client must catch.
|
|
145
|
+
if (!token)
|
|
146
|
+
return json({ user: null, subscription: null, serviceRole: null });
|
|
147
|
+
return json(await client.identity.getUser(token));
|
|
148
|
+
},
|
|
149
|
+
"GET /entitlements": async (request) => {
|
|
150
|
+
const { user } = await whoami(request);
|
|
151
|
+
const [readiness, subscription] = await Promise.all([
|
|
152
|
+
client.entitlements.getReadiness(user.organizationId),
|
|
153
|
+
client.entitlements.getSubscription(user.organizationId),
|
|
154
|
+
]);
|
|
155
|
+
return json({ readiness, subscription });
|
|
156
|
+
},
|
|
157
|
+
"GET /plans": async () => json(await client.billing.listPlans()),
|
|
158
|
+
// Organization comes from the caller's own session, same as every other
|
|
159
|
+
// route here - a browser must not be able to ask what another workspace
|
|
160
|
+
// has left.
|
|
161
|
+
"GET /addons": async (request) => {
|
|
162
|
+
const { user } = await whoami(request);
|
|
163
|
+
return json(await client.billing.getAddonUsage(user.organizationId));
|
|
164
|
+
},
|
|
165
|
+
"GET /apps": async (request) => {
|
|
166
|
+
const { user } = await whoami(request);
|
|
167
|
+
return json({ apps: await client.apps.list(user.organizationId) });
|
|
168
|
+
},
|
|
169
|
+
// Account-wide, not scoped to this service - see `SessionsResource`'s
|
|
170
|
+
// doc comment. `requireToken` alone identifies the caller; the target
|
|
171
|
+
// `sessionId` on the revoke routes travels in the JSON body, not the
|
|
172
|
+
// URL, so the exact-match routes table above never needs a dynamic
|
|
173
|
+
// segment for it.
|
|
174
|
+
"GET /sessions": async (request) => {
|
|
175
|
+
const items = await client.sessions.list(await requireToken(request));
|
|
176
|
+
return json({ items });
|
|
177
|
+
},
|
|
178
|
+
"POST /sessions/revoke": async (request) => {
|
|
179
|
+
const body = (await request.json().catch(() => null));
|
|
180
|
+
if (!body?.sessionId) {
|
|
181
|
+
throw new ConnectError({
|
|
182
|
+
status: 400,
|
|
183
|
+
code: "validation_error",
|
|
184
|
+
message: "sessionId is required",
|
|
185
|
+
endpoint: "POST /sessions/revoke",
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
await client.sessions.revoke(await requireToken(request), body.sessionId);
|
|
189
|
+
return json({ ok: true });
|
|
190
|
+
},
|
|
191
|
+
"POST /sessions/revoke-others": async (request) => {
|
|
192
|
+
const result = await client.sessions.revokeOthers(await requireToken(request));
|
|
193
|
+
return json(result);
|
|
194
|
+
},
|
|
195
|
+
/**
|
|
196
|
+
* Starts the round trip: mints the CSRF `state`, parks it in a cookie
|
|
197
|
+
* alongside the post-login destination, and bounces the browser to
|
|
198
|
+
* Connect.
|
|
199
|
+
*
|
|
200
|
+
* Registered only when the session store can `write` - without that, the
|
|
201
|
+
* callback would have nowhere to put the session it just earned.
|
|
202
|
+
*/
|
|
203
|
+
"GET /signin": async (request) => {
|
|
204
|
+
const url = new URL(request.url);
|
|
205
|
+
const secure = url.protocol === "https:";
|
|
206
|
+
const state = createState();
|
|
207
|
+
const next = safeRedirect(url.searchParams.get("next"), defaultRedirect);
|
|
208
|
+
const authorizeUrl = client.identity.authorizeUrl({
|
|
209
|
+
redirectUri: `${url.origin}${callbackPath}`,
|
|
210
|
+
state,
|
|
211
|
+
...(url.searchParams.get("prompt") === "create" ? { prompt: "create" } : {}),
|
|
212
|
+
});
|
|
213
|
+
// State and destination travel together in one cookie so the callback
|
|
214
|
+
// cannot end up with one and not the other.
|
|
215
|
+
return redirect(authorizeUrl, [stateCookie(`${state}:${next}`, secure)]);
|
|
216
|
+
},
|
|
217
|
+
/**
|
|
218
|
+
* The other end of the round trip. Verifies `state`, exchanges the code,
|
|
219
|
+
* checks the `id_token`, hands the session to the store, and returns the
|
|
220
|
+
* user where they were going.
|
|
221
|
+
*/
|
|
222
|
+
"GET /callback": async (request) => {
|
|
223
|
+
const url = new URL(request.url);
|
|
224
|
+
const secure = url.protocol === "https:";
|
|
225
|
+
const [cookieState, ...destinationParts] = (readCookie(request, stateCookieName) ?? "").split(":");
|
|
226
|
+
const next = safeRedirect(destinationParts.join(":") || null, defaultRedirect);
|
|
227
|
+
const clearState = stateCookie("", secure);
|
|
228
|
+
const returnedState = url.searchParams.get("state") ?? "";
|
|
229
|
+
if (!cookieState || !sameState(cookieState, returnedState)) {
|
|
230
|
+
// Either a forged callback or a genuinely stale tab. Neither should
|
|
231
|
+
// mint a session, and neither is worth an error page.
|
|
232
|
+
return redirect(`${next}${next.includes("?") ? "&" : "?"}connect_error=state_mismatch`, [clearState]);
|
|
233
|
+
}
|
|
234
|
+
const error = url.searchParams.get("error");
|
|
235
|
+
if (error) {
|
|
236
|
+
// `login_required` is normal traffic, not a fault: a new account gets
|
|
237
|
+
// pulled out to their inbox to verify, and the ~15 minute request
|
|
238
|
+
// expires before they return. Connect has already signed them in, so
|
|
239
|
+
// one silent retry completes in a single hop. `connect_retry` bounds
|
|
240
|
+
// it - a genuine loop must surface, not spin.
|
|
241
|
+
if (error === "login_required" && url.searchParams.get("connect_retry") !== "1") {
|
|
242
|
+
const retry = new URL(`${url.origin}${basePath}/signin`);
|
|
243
|
+
retry.searchParams.set("next", next);
|
|
244
|
+
retry.searchParams.set("connect_retry", "1");
|
|
245
|
+
return redirect(retry.toString(), [clearState]);
|
|
246
|
+
}
|
|
247
|
+
return redirect(`${next}${next.includes("?") ? "&" : "?"}connect_error=${encodeURIComponent(error)}`, [
|
|
248
|
+
clearState,
|
|
249
|
+
]);
|
|
250
|
+
}
|
|
251
|
+
const code = url.searchParams.get("code");
|
|
252
|
+
if (!code) {
|
|
253
|
+
return redirect(`${next}${next.includes("?") ? "&" : "?"}connect_error=missing_code`, [clearState]);
|
|
254
|
+
}
|
|
255
|
+
const result = await client.identity.exchangeCode({
|
|
256
|
+
code,
|
|
257
|
+
redirectUri: `${url.origin}${callbackPath}`,
|
|
258
|
+
});
|
|
259
|
+
// Verified before the session is stored, not after - an unverifiable
|
|
260
|
+
// token must never become a signed-in user.
|
|
261
|
+
await client.identity.verifyIdToken(result.idToken);
|
|
262
|
+
const response = redirect(next, [clearState]);
|
|
263
|
+
await session.write(request, response, result.sessionToken);
|
|
264
|
+
return response;
|
|
265
|
+
},
|
|
266
|
+
"POST /logout": async (request) => {
|
|
267
|
+
const token = await session.read(request);
|
|
268
|
+
const response = json({ ok: true });
|
|
269
|
+
await session.clear?.(request, response);
|
|
270
|
+
// Connect is told last, and a failure is swallowed: the user's own
|
|
271
|
+
// session is already gone, and surfacing an error would leave them
|
|
272
|
+
// looking at a signed-in UI they can no longer use.
|
|
273
|
+
if (token)
|
|
274
|
+
await client.identity.logout(token).catch(() => { });
|
|
275
|
+
return response;
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
// Without a `write`, the callback has nowhere to put the session it earns -
|
|
279
|
+
// so the two flow routes simply do not exist rather than failing at the last
|
|
280
|
+
// step of a real sign-in.
|
|
281
|
+
if (!session.write) {
|
|
282
|
+
delete routes["GET /signin"];
|
|
283
|
+
delete routes["GET /callback"];
|
|
284
|
+
}
|
|
285
|
+
return async function handle(request) {
|
|
286
|
+
const path = new URL(request.url).pathname;
|
|
287
|
+
const route = (path.startsWith(basePath) ? path.slice(basePath.length) : path).replace(/\/+$/, "") || "/";
|
|
288
|
+
const handler = routes[`${request.method} ${route}`];
|
|
289
|
+
if (!handler) {
|
|
290
|
+
return json({ error: { code: "not_found", message: `No Connect bridge route for ${route}` } }, 404);
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
return await handler(request);
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
// A `ConnectError` already carries a code the browser half understands;
|
|
297
|
+
// anything else is ours and must not leak its message outward.
|
|
298
|
+
if (error instanceof ConnectError) {
|
|
299
|
+
return json({ error: { code: error.code, message: error.message } }, error.status || 502);
|
|
300
|
+
}
|
|
301
|
+
console.error("[connect bridge] unhandled failure:", error);
|
|
302
|
+
// The message is withheld in production - an unexpected throw can carry a
|
|
303
|
+
// DSN, a stack, or a credential, and none of that belongs in a browser.
|
|
304
|
+
// In development it is included, because the alternative is what this
|
|
305
|
+
// guard originally produced: a bare "Something went wrong" on a callback,
|
|
306
|
+
// with the only real explanation buried in a terminal the developer had
|
|
307
|
+
// no reason to be watching.
|
|
308
|
+
const detail = process.env.NODE_ENV === "development" && error instanceof Error ? error.message : null;
|
|
309
|
+
return json({
|
|
310
|
+
error: {
|
|
311
|
+
code: "internal_error",
|
|
312
|
+
message: detail ? `Something went wrong: ${detail}` : "Something went wrong",
|
|
313
|
+
},
|
|
314
|
+
}, 500);
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
function json(body, status = 200) {
|
|
319
|
+
return new Response(JSON.stringify(body), {
|
|
320
|
+
status,
|
|
321
|
+
headers: {
|
|
322
|
+
"content-type": "application/json",
|
|
323
|
+
"cache-control": "no-store",
|
|
324
|
+
// Every one of these carries session-derived data. `nosniff` stops a
|
|
325
|
+
// browser second-guessing the declared type and executing a response as
|
|
326
|
+
// script, which is the shape a JSON endpoint gets turned into an XSS.
|
|
327
|
+
"x-content-type-options": "nosniff",
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { ConnectError } from "../errors.js";
|
|
2
|
+
const RETRYABLE_STATUS = new Set([408, 429, 500, 502, 503, 504]);
|
|
3
|
+
/**
|
|
4
|
+
* One id, safe to interpolate into a request path.
|
|
5
|
+
*
|
|
6
|
+
* `encodeURIComponent` alone is not enough, and the gap is easy to miss: it
|
|
7
|
+
* leaves `.` untouched, so an id of `".."` survives into the URL and the URL
|
|
8
|
+
* parser then collapses the segment *before* it - `/drive/files/../url`
|
|
9
|
+
* resolves to `/drive/url`, a different endpoint reached under the same API
|
|
10
|
+
* key. Slashes are encoded, so the traversal cannot chain past one segment,
|
|
11
|
+
* but one is already an endpoint the caller did not ask for.
|
|
12
|
+
*
|
|
13
|
+
* Relevant whenever an id reaches the SDK from a partner's own end users,
|
|
14
|
+
* which is the normal case for a file id.
|
|
15
|
+
*/
|
|
16
|
+
export function pathSegment(value, name) {
|
|
17
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
18
|
+
throw new ConnectError({
|
|
19
|
+
status: 0,
|
|
20
|
+
code: "validation_error",
|
|
21
|
+
message: `${name} is required`,
|
|
22
|
+
endpoint: "client",
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
if (value === "." || value === "..") {
|
|
26
|
+
throw new ConnectError({
|
|
27
|
+
status: 0,
|
|
28
|
+
code: "validation_error",
|
|
29
|
+
message: `${name} must not be "." or ".." - those collapse a path segment in the request URL.`,
|
|
30
|
+
endpoint: "client",
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return encodeURIComponent(value);
|
|
34
|
+
}
|
|
35
|
+
function parseRetryAfterMs(response) {
|
|
36
|
+
const header = response.headers.get("retry-after");
|
|
37
|
+
if (!header)
|
|
38
|
+
return null;
|
|
39
|
+
const seconds = Number(header);
|
|
40
|
+
return Number.isFinite(seconds) ? Math.max(0, seconds) * 1000 : null;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Both error dialects, one shape. `/api/v1/service/*` answers
|
|
44
|
+
* `{ error: { code, message } }`; the `/sso/*` endpoints answer OAuth2's
|
|
45
|
+
* `{ error, error_description }`.
|
|
46
|
+
*/
|
|
47
|
+
function toConnectError(response, body, endpoint) {
|
|
48
|
+
const envelope = (body ?? {});
|
|
49
|
+
const nested = typeof envelope.error === "object" ? envelope.error : null;
|
|
50
|
+
return new ConnectError({
|
|
51
|
+
status: response.status,
|
|
52
|
+
code: nested?.code ?? (typeof envelope.error === "string" ? envelope.error : "unknown_error"),
|
|
53
|
+
message: nested?.message ??
|
|
54
|
+
envelope.error_description ??
|
|
55
|
+
`${endpoint} failed with HTTP ${response.status}`,
|
|
56
|
+
endpoint,
|
|
57
|
+
retryAfterMs: parseRetryAfterMs(response),
|
|
58
|
+
body,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
async function readBody(response) {
|
|
62
|
+
const text = await response.text().catch(() => "");
|
|
63
|
+
if (!text)
|
|
64
|
+
return null;
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(text);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return text;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
73
|
+
/**
|
|
74
|
+
* The one place a request leaves this SDK: builds it, applies the per-attempt
|
|
75
|
+
* timeout, retries what is safe to retry, and turns every failure into a
|
|
76
|
+
* `ConnectError`.
|
|
77
|
+
*
|
|
78
|
+
* **On retries.** Only idempotent requests are retried without a caller
|
|
79
|
+
* opt-in: every `GET`, plus any `POST` carrying an `Idempotency-Key` (the
|
|
80
|
+
* resources that mutate - `billing.consumeAddon`, `otp.send` - always set
|
|
81
|
+
* one). A bare `POST` is attempted exactly once, because a 429 or a 504 says
|
|
82
|
+
* nothing about whether the server already did the work.
|
|
83
|
+
*/
|
|
84
|
+
export async function request(options, spec) {
|
|
85
|
+
const endpoint = `${spec.method} ${spec.path}`;
|
|
86
|
+
const url = new URL(`${options.baseUrl}${spec.path}`);
|
|
87
|
+
for (const [key, value] of Object.entries(spec.query ?? {})) {
|
|
88
|
+
if (value !== undefined)
|
|
89
|
+
url.searchParams.set(key, String(value));
|
|
90
|
+
}
|
|
91
|
+
const headers = {
|
|
92
|
+
accept: "application/json",
|
|
93
|
+
"user-agent": options.userAgent,
|
|
94
|
+
...spec.headers,
|
|
95
|
+
};
|
|
96
|
+
if ((spec.auth ?? "bearer") === "bearer") {
|
|
97
|
+
headers.authorization = `Bearer ${options.apiKey}`;
|
|
98
|
+
}
|
|
99
|
+
let body;
|
|
100
|
+
if (spec.multipart) {
|
|
101
|
+
// Deliberately no content-type: the runtime sets it with the boundary.
|
|
102
|
+
body = spec.multipart;
|
|
103
|
+
}
|
|
104
|
+
else if (spec.form) {
|
|
105
|
+
const form = new URLSearchParams();
|
|
106
|
+
for (const [key, value] of Object.entries(spec.form)) {
|
|
107
|
+
if (value !== undefined)
|
|
108
|
+
form.set(key, value);
|
|
109
|
+
}
|
|
110
|
+
headers["content-type"] = "application/x-www-form-urlencoded";
|
|
111
|
+
body = form.toString();
|
|
112
|
+
}
|
|
113
|
+
else if (spec.json !== undefined) {
|
|
114
|
+
headers["content-type"] = "application/json";
|
|
115
|
+
body = JSON.stringify(spec.json);
|
|
116
|
+
}
|
|
117
|
+
const idempotent = spec.method === "GET" || headers["idempotency-key"] !== undefined;
|
|
118
|
+
const attempts = idempotent ? options.maxRetries + 1 : 1;
|
|
119
|
+
let lastError;
|
|
120
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
121
|
+
if (attempt > 0) {
|
|
122
|
+
// Honour the server's own Retry-After when it sent one; otherwise back
|
|
123
|
+
// off exponentially from 250ms, capped so a long outage doesn't park a
|
|
124
|
+
// request for minutes.
|
|
125
|
+
await sleep(Math.min(lastError?.retryAfterMs ?? 250 * 2 ** (attempt - 1), 8_000));
|
|
126
|
+
}
|
|
127
|
+
const timeout = AbortSignal.timeout(options.timeoutMs);
|
|
128
|
+
const signal = spec.signal ? AbortSignal.any([spec.signal, timeout]) : timeout;
|
|
129
|
+
let response;
|
|
130
|
+
try {
|
|
131
|
+
response = await options.fetch(url.toString(), { method: spec.method, headers, body, signal });
|
|
132
|
+
}
|
|
133
|
+
catch (cause) {
|
|
134
|
+
// A caller-supplied abort is a decision, not a fault - never retried.
|
|
135
|
+
if (spec.signal?.aborted)
|
|
136
|
+
throw cause;
|
|
137
|
+
lastError = new ConnectError({
|
|
138
|
+
status: 0,
|
|
139
|
+
code: "network_error",
|
|
140
|
+
message: `${endpoint} could not reach Connect`,
|
|
141
|
+
endpoint,
|
|
142
|
+
cause,
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (response.ok) {
|
|
147
|
+
if (spec.raw)
|
|
148
|
+
return response;
|
|
149
|
+
return (await readBody(response));
|
|
150
|
+
}
|
|
151
|
+
lastError = toConnectError(response, await readBody(response), endpoint);
|
|
152
|
+
if (!RETRYABLE_STATUS.has(response.status))
|
|
153
|
+
throw lastError;
|
|
154
|
+
}
|
|
155
|
+
throw lastError;
|
|
156
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-only half of `@cosmoops/connect-sdk`.
|
|
3
|
+
*
|
|
4
|
+
* Everything here handles the API key, so none of it may run in a browser.
|
|
5
|
+
* The browser half is `@cosmoops/connect-sdk/client`, which talks to your own
|
|
6
|
+
* origin and reaches Connect through `createConnectHandler` below.
|
|
7
|
+
*/
|
|
8
|
+
export { ConnectClient, DEFAULT_BASE_URL, SDK_VERSION } from "./client.js";
|
|
9
|
+
export { createConnectHandler } from "./handler.js";
|
|
10
|
+
export { createState } from "./resources/identity.js";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { pathSegment, request } from "../http.js";
|
|
2
|
+
/** What a signed-in member may do on your service, and the role catalog behind it. */
|
|
3
|
+
export class AccessResource {
|
|
4
|
+
transport;
|
|
5
|
+
constructor(transport) {
|
|
6
|
+
this.transport = transport;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Re-check one member's role and granted permissions, live. The same block
|
|
10
|
+
* `identity.getUser()` returns - use this when you need only the role axis
|
|
11
|
+
* and already know who the user is.
|
|
12
|
+
*/
|
|
13
|
+
getUserRoles(userId, input) {
|
|
14
|
+
return request(this.transport, {
|
|
15
|
+
method: "GET",
|
|
16
|
+
path: `/rbac/users/${pathSegment(userId, "userId")}/roles`,
|
|
17
|
+
query: { organizationId: input.organizationId },
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/** Your service's own permission catalog and role definitions - the definitions `getUserRoles` resolves against. */
|
|
21
|
+
listRoles() {
|
|
22
|
+
return request(this.transport, { method: "GET", path: "/roles" });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { request } from "../http.js";
|
|
2
|
+
/** RAG chat and structured extraction against an organization's data. */
|
|
3
|
+
export class AiResource {
|
|
4
|
+
transport;
|
|
5
|
+
constructor(transport) {
|
|
6
|
+
this.transport = transport;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* RAG-grounded chat. Returns the raw streaming `Response` rather than a
|
|
10
|
+
* parsed body - this endpoint answers with server-sent events, and buffering
|
|
11
|
+
* it here would throw away the only reason to stream. Pipe
|
|
12
|
+
* `response.body` onward, or hand it to the AI SDK's own reader.
|
|
13
|
+
*/
|
|
14
|
+
chat(input, options = {}) {
|
|
15
|
+
return request(this.transport, {
|
|
16
|
+
method: "POST",
|
|
17
|
+
path: "/ai/chat",
|
|
18
|
+
json: input,
|
|
19
|
+
raw: true,
|
|
20
|
+
signal: options.signal,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/** Extract structured JSON from a file or image URL, against a platform extraction schema referenced by id. */
|
|
24
|
+
async extract(input) {
|
|
25
|
+
const result = await request(this.transport, {
|
|
26
|
+
method: "POST",
|
|
27
|
+
path: "/ai/extract",
|
|
28
|
+
json: input,
|
|
29
|
+
});
|
|
30
|
+
return result.data;
|
|
31
|
+
}
|
|
32
|
+
}
|