@tradejs/app 3.1.29-beta.261 → 3.1.29-beta.263

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.
@@ -0,0 +1,69 @@
1
+ import { readMcpBody } from '#app/lib/mcp/http';
2
+ import OAuth2Server from '@node-oauth/oauth2-server';
3
+ import { mcpStorage } from '@tradejs/infra/redis';
4
+ import {
5
+ createOAuthServer,
6
+ mcpEnabled,
7
+ mcpResource,
8
+ oauthResponse,
9
+ } from '#app/lib/mcp/oauth';
10
+
11
+ export const runtime = 'nodejs';
12
+ export const dynamic = 'force-dynamic';
13
+
14
+ export const POST = async (request: Request) => {
15
+ if (!mcpEnabled())
16
+ return oauthResponse({ error: 'temporarily_unavailable' }, 503);
17
+ if (!(await mcpStorage.rateLimit('token', 600, 60)))
18
+ return oauthResponse({ error: 'temporarily_unavailable' }, 429);
19
+ if (
20
+ !request.headers
21
+ .get('content-type')
22
+ ?.startsWith('application/x-www-form-urlencoded')
23
+ )
24
+ return oauthResponse({ error: 'invalid_request' }, 400);
25
+ let text: string;
26
+ try {
27
+ text = await readMcpBody(request, 8192);
28
+ } catch {
29
+ return oauthResponse({ error: 'invalid_request' }, 400);
30
+ }
31
+ if (text.length > 8192)
32
+ return oauthResponse({ error: 'invalid_request' }, 400);
33
+ const params = new URLSearchParams(text);
34
+ if (
35
+ [...new Set(params.keys())].some((key) => params.getAll(key).length > 1) ||
36
+ (params.has('resource') && params.get('resource') !== mcpResource()) ||
37
+ !['authorization_code', 'refresh_token'].includes(
38
+ params.get('grant_type') || '',
39
+ )
40
+ )
41
+ return oauthResponse({ error: 'invalid_request' }, 400);
42
+ const response = new OAuth2Server.Response();
43
+ try {
44
+ await createOAuthServer().token(
45
+ new OAuth2Server.Request({
46
+ method: 'POST',
47
+ headers: {
48
+ ...Object.fromEntries(request.headers),
49
+ 'content-length': String(Buffer.byteLength(text)),
50
+ },
51
+ query: {},
52
+ body: Object.fromEntries(params),
53
+ }),
54
+ response,
55
+ );
56
+ return oauthResponse(
57
+ response.body,
58
+ response.status || 200,
59
+ response.headers,
60
+ );
61
+ } catch (error) {
62
+ if (error instanceof OAuth2Server.OAuthError)
63
+ return oauthResponse(
64
+ { error: error.name, error_description: error.message },
65
+ error.code || 400,
66
+ );
67
+ return oauthResponse({ error: 'server_error' }, 503);
68
+ }
69
+ };
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useCallback, useEffect } from 'react';
3
+ import { useCallback, useEffect, useState } from 'react';
4
4
  import { useSearchParams } from 'next/navigation';
5
5
  import Link from 'next/link';
6
6
  import { Box, Button, Flex, ClientOnly } from '@chakra-ui/react';
@@ -16,6 +16,7 @@ import {
16
16
 
17
17
  const DashboardRoute = () => {
18
18
  const searchParams = useSearchParams();
19
+ const [contextCopied, setContextCopied] = useState(false);
19
20
  const { filters, setFilters } = useFilters();
20
21
  const { tickers, ensureLoaded: ensureTickersLoaded } = useTickers(
21
22
  filters.provider || 'bybit',
@@ -149,16 +150,45 @@ const DashboardRoute = () => {
149
150
  </Flex>
150
151
  </Filters.Root>
151
152
  </Box>
152
- <Button
153
- asChild
154
- bg="#20c5bd"
155
- color="gray.950"
156
- _hover={{ bg: '#42d8d0' }}
157
- justifySelf={{ base: 'start', lg: 'end' }}
158
- mb={4}
159
- >
160
- <Link href="/routes/backtest">Create backtest</Link>
161
- </Button>
153
+ <Flex gap={2} flexWrap="wrap">
154
+ <Button
155
+ onClick={async () => {
156
+ const context = {
157
+ provider: filters.provider,
158
+ universe: filters.universe ?? 'crypto',
159
+ symbol: filters.symbol,
160
+ interval: filters.interval,
161
+ ...(filters.backtestId
162
+ ? {
163
+ backtestId: filters.backtestId,
164
+ backtestStrategy: filters.backtestStrategy,
165
+ }
166
+ : {}),
167
+ };
168
+ try {
169
+ await navigator.clipboard.writeText(
170
+ `TradeJS chart context: ${JSON.stringify(context)}. Use chart_get_context for market candles; for a saved backtest, inspect its recorded results. URL: ${window.location.href}`,
171
+ );
172
+ setContextCopied(true);
173
+ } catch {
174
+ setContextCopied(false);
175
+ }
176
+ }}
177
+ variant="outline"
178
+ >
179
+ {contextCopied ? 'Context copied' : 'Copy context for AI'}
180
+ </Button>
181
+ <Button
182
+ asChild
183
+ bg="#20c5bd"
184
+ color="gray.950"
185
+ _hover={{ bg: '#42d8d0' }}
186
+ justifySelf={{ base: 'start', lg: 'end' }}
187
+ mb={4}
188
+ >
189
+ <Link href="/routes/backtest">Create backtest</Link>
190
+ </Button>
191
+ </Flex>
162
192
  </Box>
163
193
  )}
164
194
  <Box position="relative" flex="1" w="full">
@@ -0,0 +1,67 @@
1
+ import styles from '#app/lib/mcp/Mcp.module.css';
2
+ import { getCurrentUserName } from '#app/lib/currentUser';
3
+ import { readConsent } from '#app/lib/mcp/oauth';
4
+ import Link from 'next/link';
5
+
6
+ export const dynamic = 'force-dynamic';
7
+
8
+ export default async function ConsentPage({
9
+ searchParams,
10
+ }: {
11
+ searchParams: Promise<{ ticket?: string }>;
12
+ }) {
13
+ const { ticket = '' } = await searchParams;
14
+ const userName = await getCurrentUserName();
15
+ const consent = await readConsent(ticket);
16
+ if (!consent || consent.userName !== userName)
17
+ return (
18
+ <main style={{ padding: 32 }}>
19
+ Authorization request expired. Start the connection again.
20
+ </main>
21
+ );
22
+ return (
23
+ <main className={styles.main}>
24
+ <h1>Connect to TradeJS</h1>
25
+ <p>
26
+ <strong>{consent.client.name}</strong> requests access as{' '}
27
+ <strong>{userName}</strong>.
28
+ </p>
29
+ <p>The client name is supplied by the application requesting access.</p>
30
+ <p>
31
+ Return address: <code>{consent.query.redirect_uri}</code>
32
+ </p>
33
+ <p>
34
+ Market and runtime data you request will be shared with this client. Run
35
+ permissions allow background computations. They do not allow live
36
+ trading or deployment changes.
37
+ </p>
38
+ <form action="/oauth/authorize" method="post">
39
+ <input type="hidden" name="ticket" value={ticket} />
40
+ <fieldset>
41
+ <legend>Requested permissions</legend>
42
+ {consent.scopes.map((scope) => (
43
+ <label key={scope} style={{ display: 'block', margin: '8px 0' }}>
44
+ <input
45
+ type="checkbox"
46
+ name="scope"
47
+ value={scope}
48
+ defaultChecked={!scope.endsWith(':run')}
49
+ />{' '}
50
+ {scope}
51
+ </label>
52
+ ))}
53
+ </fieldset>
54
+ <button name="decision" value="allow" type="submit">
55
+ Allow access
56
+ </button>{' '}
57
+ <button name="decision" value="deny" type="submit">
58
+ Deny
59
+ </button>
60
+ </form>
61
+ <p>
62
+ You can revoke access in <Link href="/routes/mcp">MCP connections</Link>
63
+ .
64
+ </p>
65
+ </main>
66
+ );
67
+ }
@@ -0,0 +1,58 @@
1
+ import styles from '#app/lib/mcp/Mcp.module.css';
2
+ import { revalidatePath } from 'next/cache';
3
+ import { getCurrentUserName } from '#app/lib/currentUser';
4
+ import { listConnections, mcpResource, revokeGrant } from '#app/lib/mcp/oauth';
5
+
6
+ export const dynamic = 'force-dynamic';
7
+
8
+ async function revoke(form: FormData) {
9
+ 'use server';
10
+ const userName = await getCurrentUserName();
11
+ if (!userName) throw new Error('Unauthorized');
12
+ await revokeGrant(userName, String(form.get('grantId') || ''));
13
+ revalidatePath('/routes/mcp');
14
+ }
15
+
16
+ export default async function ConnectionsPage() {
17
+ const userName = await getCurrentUserName();
18
+ if (!userName) return null;
19
+ const connections = await listConnections(userName);
20
+ const url = mcpResource();
21
+ return (
22
+ <main className={styles.main}>
23
+ <h1>MCP connections</h1>
24
+ <p>
25
+ Connect Codex or Claude Code to your TradeJS instance. Sign in and
26
+ review access in your browser.
27
+ </p>
28
+ <p>
29
+ Server: <code>{url}</code>
30
+ </p>
31
+ <h2>Codex</h2>
32
+ <pre>{`codex mcp add tradejs --url ${url}\ncodex mcp login tradejs`}</pre>
33
+ <p>
34
+ For background jobs, authorize the required run permissions yourself:
35
+ </p>
36
+ <pre>{`codex mcp login tradejs --scopes market:read,runtime:read,backtests:read,backtests:run,diagnostics:read,diagnostics:run`}</pre>
37
+ <h2>Claude Code</h2>
38
+ <pre>{`claude mcp add --transport http --scope user tradejs ${url}`}</pre>
39
+ <p>
40
+ In Claude Code, open <code>/mcp</code> to authenticate. Request
41
+ additional scopes explicitly when you need to run computations.
42
+ </p>
43
+ <h2>Authorized applications</h2>
44
+ {!connections.length && <p>No active connections.</p>}
45
+ {connections.map((connection) => (
46
+ <section key={connection.id} className={styles.connection}>
47
+ <h3>{connection.clientName}</h3>
48
+ <p>{connection.scopes.join(', ')}</p>
49
+ <p>Expires: {new Date(connection.expiresAt).toISOString()}</p>
50
+ <form action={revoke}>
51
+ <input type="hidden" name="grantId" value={connection.id} />
52
+ <button type="submit">Revoke access</button>
53
+ </form>
54
+ </section>
55
+ ))}
56
+ </main>
57
+ );
58
+ }
@@ -3,7 +3,6 @@ export { useTickers } from './tickers';
3
3
  export { useIndicators } from './indicators';
4
4
  export type { IndicatorRendererConfig } from './indicators';
5
5
  export { useData } from './data';
6
- export { useAiChatStore } from './ai';
7
6
  export {
8
7
  useTest,
9
8
  useTestsCompare,
package/src/proxy.ts CHANGED
@@ -52,6 +52,24 @@ const issueSession = async (
52
52
  export const proxy = async (req: NextRequest) => {
53
53
  const { pathname } = req.nextUrl;
54
54
 
55
+ // These endpoints implement Bearer OAuth themselves. Never redirect MCP or
56
+ // discovery/token requests to the browser session login.
57
+ if (
58
+ pathname === '/mcp' ||
59
+ pathname.startsWith('/mcp/') ||
60
+ [
61
+ '/oauth/authorize',
62
+ '/oauth/token',
63
+ '/oauth/register',
64
+ '/oauth/revoke',
65
+ '/.well-known/oauth-authorization-server',
66
+ '/.well-known/oauth-protected-resource',
67
+ '/.well-known/oauth-protected-resource/mcp',
68
+ ].includes(pathname)
69
+ ) {
70
+ return NextResponse.next();
71
+ }
72
+
55
73
  if (
56
74
  pathname.startsWith('/_next') ||
57
75
  pathname === '/favicon.ico' ||
@@ -1,33 +0,0 @@
1
- import { API } from '@tradejs/core/api';
2
- import { AIChatMessage, AIChatHistory, Filters } from '@tradejs/types';
3
-
4
- interface SendMessageProps {
5
- message: AIChatMessage;
6
- filters: Filters;
7
- }
8
-
9
- const API_PATH = '/api/ai';
10
-
11
- export const getHistory = async (symbol: string): Promise<AIChatHistory> => {
12
- if (!symbol) {
13
- return [];
14
- }
15
-
16
- const data = await API.get<{ history?: AIChatHistory }>(
17
- `${API_PATH}?symbol=${encodeURIComponent(symbol)}`,
18
- );
19
-
20
- return data.history ?? [];
21
- };
22
-
23
- export const sendMessage = async ({
24
- message,
25
- filters,
26
- }: SendMessageProps): Promise<AIChatMessage> => {
27
- const data = await API.post<{ message: AIChatMessage }>(API_PATH, {
28
- message,
29
- filters,
30
- });
31
-
32
- return data.message;
33
- };
@@ -1,201 +0,0 @@
1
- import { NextRequest, NextResponse } from 'next/server';
2
- import { TTL_1M } from '@tradejs/core/constants';
3
- import { toJson } from '@tradejs/core/data';
4
- import {
5
- getAiResponseLanguagePromptName,
6
- normalizeAiResponseLanguage,
7
- } from '@tradejs/core/aiLanguages';
8
- import { invokeAiChat, type AiChatMessage } from '@tradejs/node/ai';
9
- import {
10
- AIChatHistory,
11
- AIChatMessage,
12
- ConnectorCreator,
13
- Filters,
14
- } from '@tradejs/types';
15
- import { getData, redisKeys, setData } from '@tradejs/infra/redis';
16
- import { logger } from '@tradejs/infra/logger';
17
- import { getUserSettings } from '@tradejs/infra/userSettings';
18
- import { resolveConnectorCreatorByProvider } from '#app/lib/connectorCreator';
19
- import { getCurrentUserName } from '#app/lib/currentUser';
20
-
21
- export const dynamic = 'force-dynamic';
22
-
23
- const projectRoot =
24
- String(process.env.PROJECT_CWD || process.cwd()).trim() || process.cwd();
25
-
26
- const normalizeChatSymbolKey = (symbol: string): string => {
27
- const normalized = symbol
28
- .trim()
29
- .toUpperCase()
30
- .replace(/[^A-Z0-9._-]+/g, '_')
31
- .replace(/_+/g, '_')
32
- .replace(/^_+|_+$/g, '')
33
- .slice(0, 120);
34
-
35
- if (!normalized) {
36
- throw new Error('Invalid AI chat symbol');
37
- }
38
-
39
- return normalized;
40
- };
41
-
42
- const getHistoryKey = (userName: string, symbol: string) =>
43
- redisKeys.aiChatHistory(userName, normalizeChatSymbolKey(symbol));
44
-
45
- const getHistory = async (
46
- userName: string,
47
- symbol: string,
48
- ): Promise<AIChatHistory> => {
49
- return (await getData(getHistoryKey(userName, symbol), [])) as AIChatHistory;
50
- };
51
-
52
- const appendMessagesToHistory = async (
53
- userName: string,
54
- symbol: string,
55
- messages: AIChatHistory,
56
- ): Promise<void> => {
57
- const history = await getHistory(userName, symbol);
58
- await setData(getHistoryKey(userName, symbol), [...history, ...messages], {
59
- expire: TTL_1M,
60
- });
61
- };
62
-
63
- const buildMessages = (
64
- filters: Filters,
65
- historyEntry: AIChatMessage,
66
- historyData: unknown,
67
- responseLanguage: string,
68
- ) => {
69
- const messages: AiChatMessage[] = [];
70
-
71
- messages.push({
72
- role: 'system',
73
- content: `You are a crypto trader assistant. Reply in ${getAiResponseLanguagePromptName(
74
- responseLanguage,
75
- )}.`,
76
- });
77
-
78
- messages.push({
79
- role: 'system',
80
- content: `Here is the market data for ${filters.symbol}: ${toJson(historyData)}`,
81
- });
82
-
83
- if (historyEntry.command === '/line') {
84
- messages.push({ role: 'user', content: historyEntry.text });
85
- }
86
-
87
- if (historyEntry.command === 'prompt') {
88
- messages.push({ role: 'user', content: historyEntry.text });
89
- }
90
-
91
- return messages;
92
- };
93
-
94
- export const GET = async (request: NextRequest) => {
95
- try {
96
- const userName = await getCurrentUserName();
97
- if (!userName) {
98
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
99
- }
100
-
101
- const symbol = request.nextUrl.searchParams.get('symbol');
102
-
103
- if (!symbol) {
104
- return NextResponse.json(
105
- { error: 'Missing required parameter: symbol' },
106
- { status: 400 },
107
- );
108
- }
109
-
110
- try {
111
- normalizeChatSymbolKey(symbol);
112
- } catch {
113
- return NextResponse.json({ error: 'Invalid symbol' }, { status: 400 });
114
- }
115
-
116
- const history = await getHistory(userName, symbol);
117
- return NextResponse.json({ history });
118
- } catch (error) {
119
- logger.log('error', `AI history error: %o`, error);
120
- return NextResponse.json(
121
- { error: 'Internal Server Error' },
122
- { status: 500 },
123
- );
124
- }
125
- };
126
-
127
- export const POST = async (request: NextRequest) => {
128
- try {
129
- const userName = await getCurrentUserName();
130
- if (!userName) {
131
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
132
- }
133
-
134
- const body = await request.json();
135
- const { message, filters } = body as {
136
- message?: AIChatMessage;
137
- filters?: Filters;
138
- };
139
-
140
- if (!message || !filters) {
141
- return NextResponse.json(
142
- { error: 'Missing required fields: message, filters' },
143
- { status: 400 },
144
- );
145
- }
146
-
147
- try {
148
- normalizeChatSymbolKey(filters.symbol);
149
- } catch {
150
- return NextResponse.json({ error: 'Invalid symbol' }, { status: 400 });
151
- }
152
-
153
- await appendMessagesToHistory(userName, filters.symbol, [message]);
154
-
155
- const connectorCreator = await resolveConnectorCreatorByProvider(
156
- 'bybit',
157
- projectRoot,
158
- );
159
- if (!connectorCreator) {
160
- throw new Error('No connector available for provider');
161
- }
162
-
163
- const byBitConnector = await (connectorCreator as ConnectorCreator)({
164
- userName,
165
- });
166
-
167
- const data = await byBitConnector.kline({
168
- ...filters,
169
- interval: '60',
170
- });
171
-
172
- const settings = await getUserSettings(userName);
173
- const chatMessages = buildMessages(
174
- filters,
175
- message,
176
- data.slice(-100),
177
- normalizeAiResponseLanguage(settings.AI_RESPONSE_LANGUAGE),
178
- );
179
-
180
- const response = await invokeAiChat({
181
- messages: chatMessages,
182
- userName,
183
- temperature: 0.7,
184
- });
185
-
186
- const responseMessage: AIChatMessage = {
187
- from: 'ai',
188
- text: response.content as string,
189
- };
190
-
191
- await appendMessagesToHistory(userName, filters.symbol, [responseMessage]);
192
-
193
- return NextResponse.json({ message: responseMessage });
194
- } catch (error) {
195
- logger.log('error', `AI message error: %o`, error);
196
- return NextResponse.json(
197
- { error: 'Internal Server Error' },
198
- { status: 500 },
199
- );
200
- }
201
- };
@@ -1,34 +0,0 @@
1
- import { Timeline, Box, Text } from '@chakra-ui/react';
2
- import { PiRobotFill, PiUserFill } from 'react-icons/pi';
3
- import { AIChatMessage } from '@tradejs/types';
4
-
5
- interface MessageProps {
6
- message: AIChatMessage;
7
- index: number;
8
- }
9
-
10
- export const Message = ({ message, index }: MessageProps) => {
11
- return (
12
- <Timeline.Item>
13
- <Timeline.Connector>
14
- <Timeline.Separator />
15
- <Timeline.Indicator>
16
- {message.from === 'user' ? <PiUserFill /> : <PiRobotFill />}
17
- </Timeline.Indicator>
18
- </Timeline.Connector>
19
-
20
- <Timeline.Content>
21
- <Timeline.Title fontSize="sm" fontWeight="bold">
22
- {message.from === 'user' ? 'User' : 'AI'}
23
- </Timeline.Title>
24
- <Timeline.Description fontSize="xs" color="gray.500">
25
- {/* В будущем сюда можно вставить timestamp */}
26
- Message #{index + 1}
27
- </Timeline.Description>
28
- <Box mt={1} py={2} borderRadius="lg" w="fit-content" maxW="full">
29
- <Text fontSize="sm">{message.text}</Text>
30
- </Box>
31
- </Timeline.Content>
32
- </Timeline.Item>
33
- );
34
- };