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