@qelos/sdk 4.1.1 → 4.2.1
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/README.md +81 -0
- package/dist/administrator/integration-sources.d.ts +3 -1
- package/dist/administrator/integration-sources.js +14 -0
- package/dist/administrator/integration-sources.js.map +1 -1
- package/dist/administrator/integrations.d.ts +2 -0
- package/dist/administrator/integrations.js.map +1 -1
- package/dist/administrator/payments.d.ts +17 -1
- package/dist/administrator/payments.js +12 -0
- package/dist/administrator/payments.js.map +1 -1
- package/dist/administrator/tests/integration-sources-status.test.d.ts +1 -0
- package/dist/administrator/tests/integration-sources-status.test.js +105 -0
- package/dist/administrator/tests/integration-sources-status.test.js.map +1 -0
- package/dist/authentication.d.ts +5 -0
- package/dist/authentication.js +39 -0
- package/dist/authentication.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp-auth.d.ts +76 -0
- package/dist/mcp-auth.js +216 -0
- package/dist/mcp-auth.js.map +1 -0
- package/dist/payments.d.ts +12 -1
- package/dist/payments.js.map +1 -1
- package/docs/payments.md +54 -0
- package/package.json +10 -10
- package/src/administrator/integration-sources.ts +30 -1
- package/src/administrator/integrations.ts +2 -0
- package/src/administrator/payments.ts +28 -0
- package/src/administrator/tests/integration-sources-status.test.ts +121 -0
- package/src/authentication.ts +64 -0
- package/src/index.ts +20 -0
- package/src/mcp-auth.ts +293 -0
- package/src/payments.ts +12 -1
- package/test/mcp-auth.test.ts +255 -0
- package/test/payments.test.ts +65 -0
package/src/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ const noExtraHeadersUrls = new Set([
|
|
|
16
16
|
'/api/signin',
|
|
17
17
|
'/api/signup',
|
|
18
18
|
'/api/auth/callback',
|
|
19
|
+
'/api/auth/mcp/token',
|
|
19
20
|
])
|
|
20
21
|
|
|
21
22
|
export type {
|
|
@@ -23,6 +24,13 @@ export type {
|
|
|
23
24
|
SocialAuthCallbackPayload,
|
|
24
25
|
SocialProvider,
|
|
25
26
|
SocialLoginOptions,
|
|
27
|
+
McpAuthorizeOptions,
|
|
28
|
+
McpCallbackInput,
|
|
29
|
+
McpCallbackParams,
|
|
30
|
+
McpTokenExchangeOptions,
|
|
31
|
+
McpTokenExchangeResult,
|
|
32
|
+
DecodedMcpOAuthStatePayload,
|
|
33
|
+
McpPagePathOptions,
|
|
26
34
|
} from './authentication';
|
|
27
35
|
|
|
28
36
|
export type { BlueprintEntitiesRegistry } from './blueprints';
|
|
@@ -31,8 +39,20 @@ export {
|
|
|
31
39
|
parseSocialCallbackRefreshToken,
|
|
32
40
|
getSocialAuthSetCookieParts,
|
|
33
41
|
applySocialAuthCookiesToServerResponse,
|
|
42
|
+
parseMcpCallbackParams,
|
|
34
43
|
} from './authentication';
|
|
35
44
|
|
|
45
|
+
export {
|
|
46
|
+
buildMcpAuthorizePath,
|
|
47
|
+
buildMcpAuthorizePathFromQuery,
|
|
48
|
+
buildMcpLoginRedirectUrl,
|
|
49
|
+
buildMcpConsentPageUrl,
|
|
50
|
+
resolveMcpLoginRedirect,
|
|
51
|
+
appendMcpAccessDeniedRedirect,
|
|
52
|
+
decodeMcpOAuthStatePayload,
|
|
53
|
+
buildMcpTokenExchangeBody,
|
|
54
|
+
} from './mcp-auth';
|
|
55
|
+
|
|
36
56
|
export default class QelosSDK extends BaseSDK {
|
|
37
57
|
#customHeaders = {}
|
|
38
58
|
|
package/src/mcp-auth.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import type { IUser } from './authentication';
|
|
2
|
+
|
|
3
|
+
export interface McpAuthorizeOptions {
|
|
4
|
+
redirectUri: string;
|
|
5
|
+
state?: string;
|
|
6
|
+
codeChallenge?: string;
|
|
7
|
+
codeChallengeMethod?: string;
|
|
8
|
+
clientId?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface McpTokenExchangeOptions {
|
|
12
|
+
code: string;
|
|
13
|
+
redirectUri: string;
|
|
14
|
+
codeVerifier: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface McpTokenExchangeResult {
|
|
18
|
+
accessToken: string;
|
|
19
|
+
refreshToken: string;
|
|
20
|
+
expiresIn: number;
|
|
21
|
+
user: IUser;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Callback URL or query bag from an MCP OAuth redirect. */
|
|
25
|
+
export type McpCallbackInput =
|
|
26
|
+
| string
|
|
27
|
+
| URL
|
|
28
|
+
| URLSearchParams
|
|
29
|
+
| Record<string, string | string[] | undefined>;
|
|
30
|
+
|
|
31
|
+
export interface McpCallbackParams {
|
|
32
|
+
code?: string;
|
|
33
|
+
state?: string;
|
|
34
|
+
error?: string;
|
|
35
|
+
errorDescription?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Decoded MCP OAuth state JWT payload (display only — not verified). */
|
|
39
|
+
export interface DecodedMcpOAuthStatePayload {
|
|
40
|
+
redirectUri?: string;
|
|
41
|
+
state?: string;
|
|
42
|
+
codeChallenge?: string;
|
|
43
|
+
codeChallengeMethod?: string;
|
|
44
|
+
tenant?: string;
|
|
45
|
+
clientId?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface McpPagePathOptions {
|
|
49
|
+
loginPath?: string;
|
|
50
|
+
consentPath?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readQueryValue(
|
|
54
|
+
input: Record<string, string | string[] | undefined>,
|
|
55
|
+
key: string,
|
|
56
|
+
): string | undefined {
|
|
57
|
+
const raw = input[key];
|
|
58
|
+
if (Array.isArray(raw)) {
|
|
59
|
+
return raw[0] && typeof raw[0] === 'string' ? raw[0] : undefined;
|
|
60
|
+
}
|
|
61
|
+
return typeof raw === 'string' ? raw : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function searchParamsFromString(input: string): URLSearchParams | undefined {
|
|
65
|
+
try {
|
|
66
|
+
const url = new URL(input);
|
|
67
|
+
if (url.search) {
|
|
68
|
+
return url.searchParams;
|
|
69
|
+
}
|
|
70
|
+
if (url.hash.length > 1) {
|
|
71
|
+
return new URLSearchParams(url.hash.slice(1));
|
|
72
|
+
}
|
|
73
|
+
return url.searchParams;
|
|
74
|
+
} catch {
|
|
75
|
+
const trimmed = input.trim();
|
|
76
|
+
if (!trimmed) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
const queryPart = trimmed.startsWith('?')
|
|
80
|
+
? trimmed.slice(1)
|
|
81
|
+
: trimmed.startsWith('#')
|
|
82
|
+
? trimmed.slice(1)
|
|
83
|
+
: trimmed.includes('=')
|
|
84
|
+
? trimmed
|
|
85
|
+
: undefined;
|
|
86
|
+
return queryPart ? new URLSearchParams(queryPart) : undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function searchParamsFromInput(input: McpCallbackInput): URLSearchParams | undefined {
|
|
91
|
+
if (input instanceof URLSearchParams) {
|
|
92
|
+
return input;
|
|
93
|
+
}
|
|
94
|
+
if (input instanceof URL) {
|
|
95
|
+
if (input.search.length > 1) {
|
|
96
|
+
return input.searchParams;
|
|
97
|
+
}
|
|
98
|
+
if (input.hash.length > 1) {
|
|
99
|
+
return new URLSearchParams(input.hash.slice(1));
|
|
100
|
+
}
|
|
101
|
+
return input.searchParams;
|
|
102
|
+
}
|
|
103
|
+
if (typeof input === 'string') {
|
|
104
|
+
return searchParamsFromString(input);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const params = new URLSearchParams();
|
|
108
|
+
const code = readQueryValue(input, 'code');
|
|
109
|
+
const state = readQueryValue(input, 'state');
|
|
110
|
+
const error = readQueryValue(input, 'error');
|
|
111
|
+
const errorDescription = readQueryValue(input, 'error_description');
|
|
112
|
+
if (code) params.set('code', code);
|
|
113
|
+
if (state) params.set('state', state);
|
|
114
|
+
if (error) params.set('error', error);
|
|
115
|
+
if (errorDescription) params.set('error_description', errorDescription);
|
|
116
|
+
return params.toString() ? params : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Parse authorization callback parameters from an MCP OAuth redirect.
|
|
121
|
+
* Accepts a full URL, {@link URL}, `URLSearchParams`, a query/fragment string,
|
|
122
|
+
* or a query object such as `req.query` from Next.js or Express.
|
|
123
|
+
*/
|
|
124
|
+
export function parseMcpCallbackParams(input: McpCallbackInput): McpCallbackParams {
|
|
125
|
+
const params = searchParamsFromInput(input);
|
|
126
|
+
if (!params) {
|
|
127
|
+
return {};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const result: McpCallbackParams = {};
|
|
131
|
+
const code = params.get('code');
|
|
132
|
+
const state = params.get('state');
|
|
133
|
+
const error = params.get('error');
|
|
134
|
+
const errorDescription = params.get('error_description');
|
|
135
|
+
if (code) result.code = code;
|
|
136
|
+
if (state) result.state = state;
|
|
137
|
+
if (error) result.error = error;
|
|
138
|
+
if (errorDescription) result.errorDescription = errorDescription;
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Relative authorize path with query string for {@link QlAuthentication.getMcpAuthorizeUrl}. */
|
|
143
|
+
export function buildMcpAuthorizePath(options: McpAuthorizeOptions): string {
|
|
144
|
+
const search = new URLSearchParams({ redirect_uri: options.redirectUri });
|
|
145
|
+
if (options.state) search.set('state', options.state);
|
|
146
|
+
if (options.codeChallenge) search.set('code_challenge', options.codeChallenge);
|
|
147
|
+
if (options.codeChallengeMethod) search.set('code_challenge_method', options.codeChallengeMethod);
|
|
148
|
+
if (options.clientId) search.set('client_id', options.clientId);
|
|
149
|
+
return `/api/auth/mcp/authorize?${search.toString()}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Build `/api/auth/mcp/authorize?…` from OAuth authorize query params
|
|
154
|
+
* (e.g. when `/mcp/authorize` receives a direct client redirect).
|
|
155
|
+
*/
|
|
156
|
+
export function buildMcpAuthorizePathFromQuery(
|
|
157
|
+
query: Record<string, string | string[] | undefined>,
|
|
158
|
+
): string | null {
|
|
159
|
+
const redirectUri = readQueryValue(query, 'redirect_uri');
|
|
160
|
+
if (!redirectUri) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const options: McpAuthorizeOptions = { redirectUri };
|
|
165
|
+
const state = readQueryValue(query, 'state');
|
|
166
|
+
const codeChallenge = readQueryValue(query, 'code_challenge');
|
|
167
|
+
const codeChallengeMethod = readQueryValue(query, 'code_challenge_method');
|
|
168
|
+
const clientId = readQueryValue(query, 'client_id');
|
|
169
|
+
if (state) options.state = state;
|
|
170
|
+
if (codeChallenge) options.codeChallenge = codeChallenge;
|
|
171
|
+
if (codeChallengeMethod) options.codeChallengeMethod = codeChallengeMethod;
|
|
172
|
+
if (clientId) options.clientId = clientId;
|
|
173
|
+
return buildMcpAuthorizePath(options);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Login redirect used by Qelos after an unauthenticated MCP authorize request. */
|
|
177
|
+
export function buildMcpLoginRedirectUrl(
|
|
178
|
+
authorizePath: string,
|
|
179
|
+
options?: Pick<McpPagePathOptions, 'loginPath'>,
|
|
180
|
+
): string {
|
|
181
|
+
const loginPath = options?.loginPath ?? '/login';
|
|
182
|
+
return `${loginPath}?redirect=${encodeURIComponent(authorizePath)}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Consent page URL issued after the user signs in during MCP OAuth. */
|
|
186
|
+
export function buildMcpConsentPageUrl(
|
|
187
|
+
mcpState: string,
|
|
188
|
+
options?: Pick<McpPagePathOptions, 'consentPath'>,
|
|
189
|
+
): string {
|
|
190
|
+
const consentPath = options?.consentPath ?? '/mcp/authorize';
|
|
191
|
+
return `${consentPath}?mcp_state=${encodeURIComponent(mcpState)}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Resolve the post-login redirect target from login-page query params.
|
|
196
|
+
* Matches the resume logic used by the Qelos admin login flow.
|
|
197
|
+
*/
|
|
198
|
+
export function resolveMcpLoginRedirect(
|
|
199
|
+
query: Record<string, string | string[] | undefined>,
|
|
200
|
+
options?: McpPagePathOptions,
|
|
201
|
+
): string | null {
|
|
202
|
+
const redirect = readQueryValue(query, 'redirect');
|
|
203
|
+
if (redirect) {
|
|
204
|
+
return redirect;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const mcpState = readQueryValue(query, 'mcp_state');
|
|
208
|
+
if (mcpState) {
|
|
209
|
+
return buildMcpConsentPageUrl(mcpState, options);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return buildMcpAuthorizePathFromQuery(query);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Redirect URI with `error=access_denied` for MCP consent denial. */
|
|
216
|
+
export function appendMcpAccessDeniedRedirect(
|
|
217
|
+
redirectUri: string,
|
|
218
|
+
oauthState?: string | null,
|
|
219
|
+
): string {
|
|
220
|
+
try {
|
|
221
|
+
const url = new URL(redirectUri);
|
|
222
|
+
url.searchParams.set('error', 'access_denied');
|
|
223
|
+
if (oauthState) {
|
|
224
|
+
url.searchParams.set('state', oauthState);
|
|
225
|
+
}
|
|
226
|
+
return url.toString();
|
|
227
|
+
} catch {
|
|
228
|
+
const separator = redirectUri.includes('?') ? '&' : '?';
|
|
229
|
+
let result = `${redirectUri}${separator}error=access_denied`;
|
|
230
|
+
if (oauthState) {
|
|
231
|
+
result += `&state=${encodeURIComponent(oauthState)}`;
|
|
232
|
+
}
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Decode the signed `mcp_state` JWT payload for display on a consent page.
|
|
239
|
+
* Does not verify the signature — use only for UI labels, not authorization.
|
|
240
|
+
*/
|
|
241
|
+
export function decodeMcpOAuthStatePayload(mcpState: string): DecodedMcpOAuthStatePayload | null {
|
|
242
|
+
if (!mcpState) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
const parts = mcpState.split('.');
|
|
248
|
+
if (parts.length !== 3 || !parts[1]) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
253
|
+
const json =
|
|
254
|
+
typeof globalThis.atob === 'function'
|
|
255
|
+
? globalThis.atob(base64)
|
|
256
|
+
: Buffer.from(base64, 'base64').toString('utf8');
|
|
257
|
+
const decoded = JSON.parse(json) as {
|
|
258
|
+
ru?: string;
|
|
259
|
+
s?: string;
|
|
260
|
+
cc?: string;
|
|
261
|
+
ccm?: string;
|
|
262
|
+
t?: string;
|
|
263
|
+
cid?: string;
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const payload: DecodedMcpOAuthStatePayload = {};
|
|
267
|
+
if (decoded.ru) payload.redirectUri = decoded.ru;
|
|
268
|
+
if (decoded.s) payload.state = decoded.s;
|
|
269
|
+
if (decoded.cc) payload.codeChallenge = decoded.cc;
|
|
270
|
+
if (decoded.ccm) payload.codeChallengeMethod = decoded.ccm;
|
|
271
|
+
if (decoded.t) payload.tenant = decoded.t;
|
|
272
|
+
if (decoded.cid) payload.clientId = decoded.cid;
|
|
273
|
+
return payload.redirectUri ? payload : null;
|
|
274
|
+
} catch {
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export interface McpOAuthTokenResponse {
|
|
280
|
+
access_token: string;
|
|
281
|
+
refresh_token: string;
|
|
282
|
+
token_type: string;
|
|
283
|
+
expires_in: number;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function buildMcpTokenExchangeBody(options: McpTokenExchangeOptions): URLSearchParams {
|
|
287
|
+
return new URLSearchParams({
|
|
288
|
+
grant_type: 'authorization_code',
|
|
289
|
+
code: options.code,
|
|
290
|
+
redirect_uri: options.redirectUri,
|
|
291
|
+
code_verifier: options.codeVerifier,
|
|
292
|
+
});
|
|
293
|
+
}
|
package/src/payments.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { QelosSDKOptions } from './types';
|
|
2
2
|
import BaseSDK from './base-sdk';
|
|
3
|
-
import { IPlan, ISubscription, IInvoice, ICoupon, BillingCycle } from '@qelos/global-types';
|
|
3
|
+
import { IPlan, ISubscription, IInvoice, ICoupon, BillingCycle, CheckoutCustomer } from '@qelos/global-types';
|
|
4
4
|
|
|
5
5
|
export interface CheckoutRequest {
|
|
6
6
|
/** Required when not providing `subscriptionId`. Must be a non-dynamic plan. */
|
|
@@ -10,6 +10,17 @@ export interface CheckoutRequest {
|
|
|
10
10
|
couponCode?: string;
|
|
11
11
|
successUrl?: string;
|
|
12
12
|
cancelUrl?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Real-world identity of the billable entity. Passed through to the payment
|
|
15
|
+
* provider (e.g. Sumit) so it can create a properly-named customer record
|
|
16
|
+
* instead of falling back to the raw billable entity ID.
|
|
17
|
+
*/
|
|
18
|
+
customer?: CheckoutCustomer;
|
|
19
|
+
/**
|
|
20
|
+
* When true, cancels any existing active or trialing subscription for the
|
|
21
|
+
* authenticated billable entity before starting checkout (e.g. plan change).
|
|
22
|
+
*/
|
|
23
|
+
reset?: boolean;
|
|
13
24
|
/**
|
|
14
25
|
* Pre-created subscription ID. Required for dynamic plans — the admin must set
|
|
15
26
|
* `dynamicAmount` on the subscription before this checkout call will succeed.
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import * as assert from 'node:assert/strict';
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import {
|
|
5
|
+
appendMcpAccessDeniedRedirect,
|
|
6
|
+
buildMcpAuthorizePath,
|
|
7
|
+
buildMcpAuthorizePathFromQuery,
|
|
8
|
+
buildMcpConsentPageUrl,
|
|
9
|
+
buildMcpLoginRedirectUrl,
|
|
10
|
+
buildMcpTokenExchangeBody,
|
|
11
|
+
decodeMcpOAuthStatePayload,
|
|
12
|
+
parseMcpCallbackParams,
|
|
13
|
+
resolveMcpLoginRedirect,
|
|
14
|
+
} from '../src/mcp-auth';
|
|
15
|
+
|
|
16
|
+
function pkceChallenge(verifier: string): string {
|
|
17
|
+
return createHash('sha256').update(verifier).digest('base64url');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test('buildMcpAuthorizePath', async (t) => {
|
|
21
|
+
await t.test('builds authorize path with required redirect_uri', () => {
|
|
22
|
+
const path = buildMcpAuthorizePath({
|
|
23
|
+
redirectUri: 'cursor://anysphere.cursor-mcp/oauth/callback',
|
|
24
|
+
});
|
|
25
|
+
assert.match(path, /^\/api\/auth\/mcp\/authorize\?/);
|
|
26
|
+
const parsed = new URL(`https://gateway.example.com${path}`);
|
|
27
|
+
assert.equal(
|
|
28
|
+
parsed.searchParams.get('redirect_uri'),
|
|
29
|
+
'cursor://anysphere.cursor-mcp/oauth/callback',
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await t.test('includes optional PKCE and client params', () => {
|
|
34
|
+
const path = buildMcpAuthorizePath({
|
|
35
|
+
redirectUri: 'https://claude.ai/api/mcp/auth_callback',
|
|
36
|
+
state: 'csrf-9',
|
|
37
|
+
codeChallenge: 'abc123',
|
|
38
|
+
codeChallengeMethod: 'S256',
|
|
39
|
+
clientId: 'claude',
|
|
40
|
+
});
|
|
41
|
+
const parsed = new URL(`https://gateway.example.com${path}`);
|
|
42
|
+
assert.equal(parsed.searchParams.get('state'), 'csrf-9');
|
|
43
|
+
assert.equal(parsed.searchParams.get('code_challenge'), 'abc123');
|
|
44
|
+
assert.equal(parsed.searchParams.get('code_challenge_method'), 'S256');
|
|
45
|
+
assert.equal(parsed.searchParams.get('client_id'), 'claude');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('getMcpAuthorizeUrl', async (t) => {
|
|
50
|
+
const { default: QelosSDK } = await import('../src/index');
|
|
51
|
+
|
|
52
|
+
await t.test('returns absolute authorize URL', () => {
|
|
53
|
+
const sdk = new QelosSDK({ appUrl: 'https://gateway.example.com' });
|
|
54
|
+
const url = sdk.authentication.getMcpAuthorizeUrl({
|
|
55
|
+
redirectUri: 'https://claude.ai/api/mcp/auth_callback',
|
|
56
|
+
state: 's1',
|
|
57
|
+
});
|
|
58
|
+
const parsed = new URL(url);
|
|
59
|
+
assert.equal(parsed.origin, 'https://gateway.example.com');
|
|
60
|
+
assert.equal(parsed.pathname, '/api/auth/mcp/authorize');
|
|
61
|
+
assert.equal(parsed.searchParams.get('redirect_uri'), 'https://claude.ai/api/mcp/auth_callback');
|
|
62
|
+
assert.equal(parsed.searchParams.get('state'), 's1');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('parseMcpCallbackParams', async (t) => {
|
|
67
|
+
await t.test('parses code and state from full URL', () => {
|
|
68
|
+
assert.deepEqual(
|
|
69
|
+
parseMcpCallbackParams('https://claude.ai/api/mcp/auth_callback?code=abc&state=xyz'),
|
|
70
|
+
{ code: 'abc', state: 'xyz' },
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await t.test('parses error params from callback URL', () => {
|
|
75
|
+
assert.deepEqual(
|
|
76
|
+
parseMcpCallbackParams('cursor://cb?error=access_denied&state=xyz'),
|
|
77
|
+
{ error: 'access_denied', state: 'xyz' },
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await t.test('parses URL / URLSearchParams / query objects', () => {
|
|
82
|
+
assert.deepEqual(
|
|
83
|
+
parseMcpCallbackParams(new URL('http://_/cb?code=u&state=v')),
|
|
84
|
+
{ code: 'u', state: 'v' },
|
|
85
|
+
);
|
|
86
|
+
assert.deepEqual(
|
|
87
|
+
parseMcpCallbackParams(new URLSearchParams('code=w&state=x')),
|
|
88
|
+
{ code: 'w', state: 'x' },
|
|
89
|
+
);
|
|
90
|
+
assert.deepEqual(parseMcpCallbackParams({ code: 'y', state: 'z' }), { code: 'y', state: 'z' });
|
|
91
|
+
assert.deepEqual(parseMcpCallbackParams({ error: ['denied'] }), { error: 'denied' });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await t.test('parses fragment delivery params', () => {
|
|
95
|
+
assert.deepEqual(
|
|
96
|
+
parseMcpCallbackParams('https://app.example/cb#code=frag-code&state=frag-state'),
|
|
97
|
+
{ code: 'frag-code', state: 'frag-state' },
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await t.test('returns empty object when params missing', () => {
|
|
102
|
+
assert.deepEqual(parseMcpCallbackParams('/no-query'), {});
|
|
103
|
+
assert.deepEqual(parseMcpCallbackParams({}), {});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('exchangeMcpAuthorizationCode', async (t) => {
|
|
108
|
+
const { default: QelosSDK } = await import('../src/index');
|
|
109
|
+
|
|
110
|
+
await t.test('posts PKCE token exchange and returns tokens with user', async () => {
|
|
111
|
+
const codeVerifier = randomBytes(32).toString('base64url');
|
|
112
|
+
const redirectUri = 'https://claude.ai/api/mcp/auth_callback';
|
|
113
|
+
const calls: { url: string; init?: RequestInit }[] = [];
|
|
114
|
+
|
|
115
|
+
const sdk = new QelosSDK({
|
|
116
|
+
appUrl: 'https://gateway.example.com',
|
|
117
|
+
fetch: async (url, init) => {
|
|
118
|
+
calls.push({ url: String(url), init });
|
|
119
|
+
if (String(url).endsWith('/api/auth/mcp/token')) {
|
|
120
|
+
const body = init?.body ? String(init.body) : '';
|
|
121
|
+
const params = new URLSearchParams(body);
|
|
122
|
+
assert.equal(params.get('grant_type'), 'authorization_code');
|
|
123
|
+
assert.equal(params.get('code'), 'auth-code-1');
|
|
124
|
+
assert.equal(params.get('redirect_uri'), redirectUri);
|
|
125
|
+
assert.equal(params.get('code_verifier'), codeVerifier);
|
|
126
|
+
return new Response(
|
|
127
|
+
JSON.stringify({
|
|
128
|
+
access_token: 'at-1',
|
|
129
|
+
refresh_token: 'rt-1',
|
|
130
|
+
token_type: 'Bearer',
|
|
131
|
+
expires_in: 18000,
|
|
132
|
+
}),
|
|
133
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (String(url).endsWith('/api/me')) {
|
|
137
|
+
return new Response(
|
|
138
|
+
JSON.stringify({ _id: 'u1', username: 'jane', email: 'jane@example.com' }),
|
|
139
|
+
{ status: 200, headers: { 'content-type': 'application/json' } },
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return new Response('not found', { status: 404 });
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const result = await sdk.authentication.exchangeMcpAuthorizationCode({
|
|
147
|
+
code: 'auth-code-1',
|
|
148
|
+
redirectUri,
|
|
149
|
+
codeVerifier,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
assert.equal(result.accessToken, 'at-1');
|
|
153
|
+
assert.equal(result.refreshToken, 'rt-1');
|
|
154
|
+
assert.equal(result.expiresIn, 18000);
|
|
155
|
+
assert.equal(result.user._id, 'u1');
|
|
156
|
+
assert.equal(calls.length, 2);
|
|
157
|
+
assert.match(calls[0]!.url, /\/api\/auth\/mcp\/token$/);
|
|
158
|
+
assert.match(calls[1]!.url, /\/api\/me$/);
|
|
159
|
+
assert.equal(
|
|
160
|
+
(calls[0]!.init?.headers as Record<string, string>)['content-type'],
|
|
161
|
+
'application/x-www-form-urlencoded',
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await t.test('buildMcpTokenExchangeBody matches authorize PKCE pair', () => {
|
|
166
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
167
|
+
const challenge = pkceChallenge(verifier);
|
|
168
|
+
const path = buildMcpAuthorizePath({
|
|
169
|
+
redirectUri: 'cursor://anysphere.cursor-mcp/oauth/callback',
|
|
170
|
+
codeChallenge: challenge,
|
|
171
|
+
codeChallengeMethod: 'S256',
|
|
172
|
+
});
|
|
173
|
+
const authorizeParams = new URL(`https://gateway.example.com${path}`).searchParams;
|
|
174
|
+
assert.equal(authorizeParams.get('code_challenge'), challenge);
|
|
175
|
+
assert.equal(authorizeParams.get('code_challenge_method'), 'S256');
|
|
176
|
+
|
|
177
|
+
const body = buildMcpTokenExchangeBody({
|
|
178
|
+
code: 'c1',
|
|
179
|
+
redirectUri: 'cursor://anysphere.cursor-mcp/oauth/callback',
|
|
180
|
+
codeVerifier: verifier,
|
|
181
|
+
});
|
|
182
|
+
assert.equal(body.get('code_verifier'), verifier);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('mcp authorize page helpers', async (t) => {
|
|
187
|
+
await t.test('buildMcpLoginRedirectUrl encodes authorize path', () => {
|
|
188
|
+
const authorizePath = '/api/auth/mcp/authorize?redirect_uri=https%3A%2F%2Fclaude.ai';
|
|
189
|
+
assert.equal(
|
|
190
|
+
buildMcpLoginRedirectUrl(authorizePath),
|
|
191
|
+
`/login?redirect=${encodeURIComponent(authorizePath)}`,
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await t.test('buildMcpConsentPageUrl encodes mcp_state', () => {
|
|
196
|
+
assert.equal(
|
|
197
|
+
buildMcpConsentPageUrl('signed.jwt.state'),
|
|
198
|
+
`/mcp/authorize?mcp_state=${encodeURIComponent('signed.jwt.state')}`,
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await t.test('buildMcpAuthorizePathFromQuery mirrors authorize params', () => {
|
|
203
|
+
const path = buildMcpAuthorizePathFromQuery({
|
|
204
|
+
redirect_uri: 'cursor://anysphere.cursor-mcp/oauth/callback',
|
|
205
|
+
state: 's1',
|
|
206
|
+
code_challenge: 'cc',
|
|
207
|
+
code_challenge_method: 'S256',
|
|
208
|
+
client_id: 'cursor',
|
|
209
|
+
});
|
|
210
|
+
assert.ok(path);
|
|
211
|
+
const parsed = new URL(`https://gateway.example.com${path}`);
|
|
212
|
+
assert.equal(parsed.searchParams.get('redirect_uri'), 'cursor://anysphere.cursor-mcp/oauth/callback');
|
|
213
|
+
assert.equal(parsed.searchParams.get('state'), 's1');
|
|
214
|
+
assert.equal(parsed.searchParams.get('code_challenge'), 'cc');
|
|
215
|
+
assert.equal(parsed.searchParams.get('code_challenge_method'), 'S256');
|
|
216
|
+
assert.equal(parsed.searchParams.get('client_id'), 'cursor');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
await t.test('resolveMcpLoginRedirect prefers redirect, then mcp_state, then authorize query', () => {
|
|
220
|
+
assert.equal(
|
|
221
|
+
resolveMcpLoginRedirect({ redirect: '/api/auth/mcp/authorize?redirect_uri=x' }),
|
|
222
|
+
'/api/auth/mcp/authorize?redirect_uri=x',
|
|
223
|
+
);
|
|
224
|
+
assert.equal(
|
|
225
|
+
resolveMcpLoginRedirect({ mcp_state: 'jwt' }),
|
|
226
|
+
`/mcp/authorize?mcp_state=${encodeURIComponent('jwt')}`,
|
|
227
|
+
);
|
|
228
|
+
assert.equal(
|
|
229
|
+
resolveMcpLoginRedirect({ redirect_uri: 'https://claude.ai/cb' }),
|
|
230
|
+
'/api/auth/mcp/authorize?redirect_uri=https%3A%2F%2Fclaude.ai%2Fcb',
|
|
231
|
+
);
|
|
232
|
+
assert.equal(resolveMcpLoginRedirect({}), null);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
await t.test('appendMcpAccessDeniedRedirect adds error and state', () => {
|
|
236
|
+
assert.equal(
|
|
237
|
+
appendMcpAccessDeniedRedirect('cursor://cb?foo=1', 'oauth-state'),
|
|
238
|
+
'cursor://cb?foo=1&error=access_denied&state=oauth-state',
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
await t.test('decodeMcpOAuthStatePayload decodes JWT payload without verification', () => {
|
|
243
|
+
const payload = { ru: 'cursor://cb', s: 'oauth-state', t: 'tenant-1', cid: 'cursor' };
|
|
244
|
+
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
245
|
+
const token = `header.${encoded}.signature`;
|
|
246
|
+
|
|
247
|
+
assert.deepEqual(decodeMcpOAuthStatePayload(token), {
|
|
248
|
+
redirectUri: 'cursor://cb',
|
|
249
|
+
state: 'oauth-state',
|
|
250
|
+
tenant: 'tenant-1',
|
|
251
|
+
clientId: 'cursor',
|
|
252
|
+
});
|
|
253
|
+
assert.equal(decodeMcpOAuthStatePayload('not-a-jwt'), null);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import * as assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import QlPayments from '../src/payments';
|
|
4
|
+
import { QelosSDKOptions } from '../src/types';
|
|
5
|
+
|
|
6
|
+
function jsonResponse(body: unknown) {
|
|
7
|
+
const res = new Response(JSON.stringify(body));
|
|
8
|
+
res.headers.set('Content-Type', 'application/json');
|
|
9
|
+
return res;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
test('QlPayments', async (t) => {
|
|
13
|
+
await t.test('checkout serializes the customer object into the POST body', async () => {
|
|
14
|
+
let init: RequestInit | undefined;
|
|
15
|
+
const options: QelosSDKOptions = {
|
|
16
|
+
appUrl: 'http://localhost:3000',
|
|
17
|
+
fetch: async (_url, requestInit) => {
|
|
18
|
+
init = requestInit;
|
|
19
|
+
return jsonResponse({ subscriptionId: 'sub-1', checkoutUrl: 'https://sumit.example/checkout' });
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
const payments = new QlPayments(options);
|
|
23
|
+
await payments.checkout({
|
|
24
|
+
planId: 'plan-1',
|
|
25
|
+
billingCycle: 'monthly',
|
|
26
|
+
successUrl: 'https://app.example/success',
|
|
27
|
+
cancelUrl: 'https://app.example/cancel',
|
|
28
|
+
customer: {
|
|
29
|
+
name: 'Jane Doe',
|
|
30
|
+
nameForInvoice: 'Acme Inc.',
|
|
31
|
+
email: 'jane@acme.example',
|
|
32
|
+
phone: '+1-555-0100',
|
|
33
|
+
address: '1 Main St',
|
|
34
|
+
city: 'Springfield',
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
assert.equal(init?.method, 'post');
|
|
39
|
+
const body = JSON.parse(init?.body as string);
|
|
40
|
+
assert.deepEqual(body.customer, {
|
|
41
|
+
name: 'Jane Doe',
|
|
42
|
+
nameForInvoice: 'Acme Inc.',
|
|
43
|
+
email: 'jane@acme.example',
|
|
44
|
+
phone: '+1-555-0100',
|
|
45
|
+
address: '1 Main St',
|
|
46
|
+
city: 'Springfield',
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
await t.test('checkout omits customer when not provided', async () => {
|
|
51
|
+
let init: RequestInit | undefined;
|
|
52
|
+
const options: QelosSDKOptions = {
|
|
53
|
+
appUrl: 'http://localhost:3000',
|
|
54
|
+
fetch: async (_url, requestInit) => {
|
|
55
|
+
init = requestInit;
|
|
56
|
+
return jsonResponse({ subscriptionId: 'sub-1' });
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
const payments = new QlPayments(options);
|
|
60
|
+
await payments.checkout({ planId: 'plan-1', billingCycle: 'monthly' });
|
|
61
|
+
|
|
62
|
+
const body = JSON.parse(init?.body as string);
|
|
63
|
+
assert.equal('customer' in body, false);
|
|
64
|
+
});
|
|
65
|
+
});
|