@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.
Files changed (126) hide show
  1. package/LICENSE +34 -0
  2. package/README.md +446 -0
  3. package/dist/cjs/cli/bin.js +16 -0
  4. package/dist/cjs/cli/init.js +179 -0
  5. package/dist/cjs/client/components/account-sessions.js +112 -0
  6. package/dist/cjs/client/components/apps-grid.js +46 -0
  7. package/dist/cjs/client/components/auth-buttons.js +42 -0
  8. package/dist/cjs/client/components/feature-gate.js +61 -0
  9. package/dist/cjs/client/components/link-button.js +24 -0
  10. package/dist/cjs/client/components/logo.js +27 -0
  11. package/dist/cjs/client/components/plan-pricing.js +166 -0
  12. package/dist/cjs/client/components/plans-page.js +39 -0
  13. package/dist/cjs/client/components/plans.js +212 -0
  14. package/dist/cjs/client/components/readiness.js +117 -0
  15. package/dist/cjs/client/components/subscribe-dialog.js +60 -0
  16. package/dist/cjs/client/components/subscription.js +189 -0
  17. package/dist/cjs/client/components/usage-meter.js +68 -0
  18. package/dist/cjs/client/components/user-menu.js +172 -0
  19. package/dist/cjs/client/index.js +99 -0
  20. package/dist/cjs/client/mark.js +16 -0
  21. package/dist/cjs/client/provider.js +259 -0
  22. package/dist/cjs/client/use-checkout.js +173 -0
  23. package/dist/cjs/errors.js +74 -0
  24. package/dist/cjs/format-relative-time.js +18 -0
  25. package/dist/cjs/helpers.js +30 -0
  26. package/dist/cjs/index.js +22 -0
  27. package/dist/cjs/next/index.js +311 -0
  28. package/dist/cjs/package.json +3 -0
  29. package/dist/cjs/server/client.js +114 -0
  30. package/dist/cjs/server/handler.js +333 -0
  31. package/dist/cjs/server/http.js +160 -0
  32. package/dist/cjs/server/index.js +18 -0
  33. package/dist/cjs/server/resources/access.js +28 -0
  34. package/dist/cjs/server/resources/ai.js +36 -0
  35. package/dist/cjs/server/resources/apps.js +30 -0
  36. package/dist/cjs/server/resources/billing.js +71 -0
  37. package/dist/cjs/server/resources/drive.js +39 -0
  38. package/dist/cjs/server/resources/entitlements.js +49 -0
  39. package/dist/cjs/server/resources/identity.js +194 -0
  40. package/dist/cjs/server/resources/logs.js +41 -0
  41. package/dist/cjs/server/resources/otp.js +29 -0
  42. package/dist/cjs/server/resources/sessions.js +82 -0
  43. package/dist/cjs/types.js +15 -0
  44. package/dist/esm/cli/bin.js +13 -0
  45. package/dist/esm/cli/init.js +174 -0
  46. package/dist/esm/client/components/account-sessions.js +109 -0
  47. package/dist/esm/client/components/apps-grid.js +43 -0
  48. package/dist/esm/client/components/auth-buttons.js +38 -0
  49. package/dist/esm/client/components/feature-gate.js +57 -0
  50. package/dist/esm/client/components/link-button.js +21 -0
  51. package/dist/esm/client/components/logo.js +24 -0
  52. package/dist/esm/client/components/plan-pricing.js +156 -0
  53. package/dist/esm/client/components/plans-page.js +36 -0
  54. package/dist/esm/client/components/plans.js +209 -0
  55. package/dist/esm/client/components/readiness.js +113 -0
  56. package/dist/esm/client/components/subscribe-dialog.js +57 -0
  57. package/dist/esm/client/components/subscription.js +181 -0
  58. package/dist/esm/client/components/usage-meter.js +65 -0
  59. package/dist/esm/client/components/user-menu.js +164 -0
  60. package/dist/esm/client/index.js +47 -0
  61. package/dist/esm/client/mark.js +13 -0
  62. package/dist/esm/client/provider.js +247 -0
  63. package/dist/esm/client/use-checkout.js +168 -0
  64. package/dist/esm/errors.js +69 -0
  65. package/dist/esm/format-relative-time.js +15 -0
  66. package/dist/esm/helpers.js +26 -0
  67. package/dist/esm/index.js +15 -0
  68. package/dist/esm/next/index.js +279 -0
  69. package/dist/esm/package.json +3 -0
  70. package/dist/esm/server/client.js +110 -0
  71. package/dist/esm/server/handler.js +330 -0
  72. package/dist/esm/server/http.js +156 -0
  73. package/dist/esm/server/index.js +10 -0
  74. package/dist/esm/server/resources/access.js +24 -0
  75. package/dist/esm/server/resources/ai.js +32 -0
  76. package/dist/esm/server/resources/apps.js +26 -0
  77. package/dist/esm/server/resources/billing.js +67 -0
  78. package/dist/esm/server/resources/drive.js +35 -0
  79. package/dist/esm/server/resources/entitlements.js +45 -0
  80. package/dist/esm/server/resources/identity.js +189 -0
  81. package/dist/esm/server/resources/logs.js +37 -0
  82. package/dist/esm/server/resources/otp.js +25 -0
  83. package/dist/esm/server/resources/sessions.js +78 -0
  84. package/dist/esm/types.js +14 -0
  85. package/dist/theme.css +84 -0
  86. package/dist/types/cli/bin.d.ts +1 -0
  87. package/dist/types/cli/init.d.ts +34 -0
  88. package/dist/types/client/components/account-sessions.d.ts +25 -0
  89. package/dist/types/client/components/apps-grid.d.ts +20 -0
  90. package/dist/types/client/components/auth-buttons.d.ts +31 -0
  91. package/dist/types/client/components/feature-gate.d.ts +57 -0
  92. package/dist/types/client/components/link-button.d.ts +20 -0
  93. package/dist/types/client/components/logo.d.ts +35 -0
  94. package/dist/types/client/components/plan-pricing.d.ts +96 -0
  95. package/dist/types/client/components/plans-page.d.ts +36 -0
  96. package/dist/types/client/components/plans.d.ts +34 -0
  97. package/dist/types/client/components/readiness.d.ts +52 -0
  98. package/dist/types/client/components/subscribe-dialog.d.ts +34 -0
  99. package/dist/types/client/components/subscription.d.ts +123 -0
  100. package/dist/types/client/components/usage-meter.d.ts +24 -0
  101. package/dist/types/client/components/user-menu.d.ts +86 -0
  102. package/dist/types/client/index.d.ts +47 -0
  103. package/dist/types/client/mark.d.ts +11 -0
  104. package/dist/types/client/provider.d.ts +209 -0
  105. package/dist/types/client/use-checkout.d.ts +93 -0
  106. package/dist/types/errors.d.ts +62 -0
  107. package/dist/types/format-relative-time.d.ts +2 -0
  108. package/dist/types/helpers.d.ts +33 -0
  109. package/dist/types/index.d.ts +17 -0
  110. package/dist/types/next/index.d.ts +136 -0
  111. package/dist/types/server/client.d.ts +83 -0
  112. package/dist/types/server/handler.d.ts +67 -0
  113. package/dist/types/server/http.d.ts +52 -0
  114. package/dist/types/server/index.d.ts +15 -0
  115. package/dist/types/server/resources/access.d.ts +17 -0
  116. package/dist/types/server/resources/ai.d.ts +24 -0
  117. package/dist/types/server/resources/apps.d.ts +12 -0
  118. package/dist/types/server/resources/billing.d.ts +41 -0
  119. package/dist/types/server/resources/drive.d.ts +26 -0
  120. package/dist/types/server/resources/entitlements.d.ts +31 -0
  121. package/dist/types/server/resources/identity.d.ts +76 -0
  122. package/dist/types/server/resources/logs.d.ts +20 -0
  123. package/dist/types/server/resources/otp.d.ts +17 -0
  124. package/dist/types/server/resources/sessions.d.ts +37 -0
  125. package/dist/types/types.d.ts +434 -0
  126. 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
+ }