diff --git a/.husky/pre-commit b/.husky/pre-commit index f54fc9cd5..ea5a55b6f 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -bunx lint-staged \ No newline at end of file +bunx lint-staged diff --git a/AGENTS.md b/AGENTS.md index e0f8f73cb..9f8ccc895 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,8 +6,7 @@ - Do not add legacy support; updates should be clean and avoid extra project complexity. - We do not need any form of legacy support as the project is under fresh dev, do not add any form of legacy backfill path - This project does not support any legacy methods. -- Ignore all license related issues. -- Project uses `Bun` pacakge manager with turborepo. +- Project uses `Bun` pacakge manager with turborepo, find project defined scripts in `/pacakge.json` for testing. - Prefer removing lines of code over adding more lines of code to reduce project complexity. ## Planning diff --git a/README.md b/README.md index 1ab1ee07a..dc053ec78 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,33 @@ It is built for analytics, research, charting, monitoring, and workflow automati Project Overview +--- + +### Copilot-MCP + +You can install TradingGoose MCP to use any local agentic tool like Codex, Claude Code, Cursor, ZCode as Copilot to perform TradingGoose-Studio operations + +#### Mac/Linux: +connect to the hosted instance: +``` +curl -fsSL https://TradingGoose.ai/mcp/setup | sh +``` +connect to self-hosted instance: +``` +curl -fsSL http://localhost:3000/mcp/setup | sh +``` + +#### Windows +connect to the hosted instance: +``` +irm https://TradingGoose.ai/mcp/setup | iex +``` + +connect to self-hosted instance: +``` +irm http://localhost:3000/mcp/setup | iex +``` ## Quick Start @@ -86,12 +112,10 @@ cd ../../packages/db && cp .env.example .env #### 4. Run database migrations ``` -cd packages/db -bunx drizzle-kit migrate --config=./drizzle.config.ts +bun run db:migrate ``` -#### 5. Start development servers +#### 5. Start full development servers ``` -cd ../.. bun run dev:full ``` @@ -101,9 +125,9 @@ If you use Docker Compose, copy `apps/tradinggoose/.env.example.docker` to `apps/tradinggoose/.env` and set the required secrets before running the compose manifests. The `.env` must include `POSTGRES_*`, `NEXT_PUBLIC_APP_URL`, `NEXT_PUBLIC_SOCKET_URL`, `BETTER_AUTH_SECRET`, -`ENCRYPTION_KEY`, `API_ENCRYPTION_KEY`, and `INTERNAL_API_SECRET`. The -`ENCRYPTION_KEY` value is shared by both the app and realtime containers, and -`API_ENCRYPTION_KEY` enables encrypted API-key storage in the app container. +`ENCRYPTION_KEY`, and `INTERNAL_API_SECRET`. Set `API_ENCRYPTION_KEY` when +API-key access or MCP token issuance is used; it encrypts API keys at rest in +the app container. `NEXT_PUBLIC_SOCKET_URL` should point at `http://localhost:3002` for local Compose runs; production deployments must override it with a browser-reachable public URL. The prod and Ollama compose files also require `IMAGE_TAG` and diff --git a/apps/tradinggoose/.env.example b/apps/tradinggoose/.env.example index c12be0f53..b0b75a108 100644 --- a/apps/tradinggoose/.env.example +++ b/apps/tradinggoose/.env.example @@ -43,9 +43,9 @@ BETTER_AUTH_SECRET="replace-with-64-hex-characters" # Generate a secure 64-character hex secret. ENCRYPTION_KEY="replace-with-64-hex-characters" -# Recommended: dedicated encryption key for stored API credentials. +# Optional unless API-key access or MCP token issuance is used. # Generate a secure 64-character hex secret. -API_ENCRYPTION_KEY="replace-with-64-hex-characters" +API_ENCRYPTION_KEY="" # Required: internal server-to-server auth secret used by app routes, sockets, # cron endpoints, and other internal calls. diff --git a/apps/tradinggoose/.env.example.docker b/apps/tradinggoose/.env.example.docker index 21191cd1e..50777078d 100644 --- a/apps/tradinggoose/.env.example.docker +++ b/apps/tradinggoose/.env.example.docker @@ -25,6 +25,9 @@ OLLAMA_IMAGE_TAG=latest # Security (Required) # Use `openssl rand -hex 32` to generate, used to encrypt environment variables ENCRYPTION_KEY=generate-the-key +# Optional unless API-key access or MCP token issuance is used. +# Use `openssl rand -hex 32` to generate, used to encrypt API keys +API_ENCRYPTION_KEY= # Use `openssl rand -hex 32` to generate, used to encrypt internal api routes INTERNAL_API_SECRET=generate-the-secret diff --git a/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.test.tsx b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.test.tsx new file mode 100644 index 000000000..cb05b1bf1 --- /dev/null +++ b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.test.tsx @@ -0,0 +1,101 @@ +import type React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockGetSession, + mockGetSessionCookie, + mockHeaders, + mockCreateMcpDeviceLoginApprovalChallenge, + mockRedirect, +} = vi.hoisted(() => ({ + mockCreateMcpDeviceLoginApprovalChallenge: vi.fn(), + mockGetSession: vi.fn(), + mockGetSessionCookie: vi.fn(), + mockHeaders: vi.fn(), + mockRedirect: vi.fn(), +})) + +vi.mock('next/headers', () => ({ + headers: () => mockHeaders(), +})) + +vi.mock('better-auth/cookies', () => ({ + getSessionCookie: (...args: unknown[]) => mockGetSessionCookie(...args), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: (...args: unknown[]) => mockGetSession(...args), +})) + +vi.mock('@/lib/mcp/auth', () => ({ + createMcpDeviceLoginApprovalChallenge: (...args: unknown[]) => + mockCreateMcpDeviceLoginApprovalChallenge(...args), +})) + +vi.mock('@/app/(auth)/components/auth-page-header', () => ({ + AuthPageHeader: ({ + description, + eyebrow, + title, + }: { + description: string + eyebrow: string + title: string + }) => ( +
+

{eyebrow}

+

{title}

+

{description}

+
+ ), +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ), +})) + +vi.mock('@/app/fonts/inter', () => ({ + inter: { className: 'inter' }, +})) + +vi.mock('@/i18n/navigation', () => ({ + redirect: (...args: unknown[]) => mockRedirect(...args), +})) + +describe('MCP authorize page', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + mockHeaders.mockResolvedValue(new Headers()) + mockGetSessionCookie.mockReturnValue(null) + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockCreateMcpDeviceLoginApprovalChallenge.mockResolvedValue({ + status: 'pending', + approvalToken: 'approval-token', + expiresAt: '2026-06-19T12:00:00.000Z', + }) + }) + + it('renders a confirmation form with a user-bound approval challenge', async () => { + const McpAuthorizePage = (await import('./page')).default + + const result = await McpAuthorizePage({ + params: Promise.resolve({ locale: 'es' }), + searchParams: Promise.resolve({ code: 'login-code' }), + }) + const markup = renderToStaticMarkup(result) + + expect(mockCreateMcpDeviceLoginApprovalChallenge).toHaveBeenCalledWith({ + code: 'login-code', + userId: 'user-1', + }) + expect(markup).toContain('Aprobar clave API personal') + expect(markup).toContain('method="post"') + expect(markup).toContain('action="/api/auth/mcp/authorize"') + expect(markup).toContain('name="approvalToken"') + expect(markup).toContain('value="approval-token"') + }) +}) diff --git a/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx new file mode 100644 index 000000000..0bd3f2437 --- /dev/null +++ b/apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx @@ -0,0 +1,121 @@ +import { getSessionCookie } from 'better-auth/cookies' +import { headers } from 'next/headers' +import { Button } from '@/components/ui/button' +import { getSession } from '@/lib/auth' +import { createMcpDeviceLoginApprovalChallenge } from '@/lib/mcp/auth' +import { AuthPageHeader } from '@/app/(auth)/components/auth-page-header' +import { inter } from '@/app/fonts/inter' +import { redirect } from '@/i18n/navigation' +import { getPublicCopy } from '@/i18n/public-copy' +import { normalizeLocaleCode } from '@/i18n/utils' + +export const dynamic = 'force-dynamic' + +type SearchParams = Promise<{ + code?: string | string[] + status?: string | string[] +}> + +export default async function McpAuthorizePage({ + params, + searchParams, +}: { + params: Promise<{ locale: string }> + searchParams: SearchParams +}) { + const [{ locale: routeLocale }, query, requestHeaders] = await Promise.all([ + params, + searchParams, + headers(), + ]) + const locale = normalizeLocaleCode(routeLocale) + const mcpCopy = getPublicCopy(locale).auth.mcp + const code = Array.isArray(query.code) ? query.code[0] : query.code + const rawStatus = Array.isArray(query.status) ? query.status[0] : query.status + const statusCopy = + rawStatus === 'approved' + ? mcpCopy.approved + : rawStatus === 'cancelled' + ? mcpCopy.cancelled + : rawStatus === 'expired' + ? mcpCopy.expired + : rawStatus === 'invalid' + ? mcpCopy.invalid + : null + const renderStatus = (copy: { title: string; description: string }) => ( +
+ +
+ ) + + if (statusCopy) { + return renderStatus(statusCopy) + } + + if (!code) { + return renderStatus(mcpCopy.invalid) + } + + const session = await getSession(requestHeaders) + if (!session?.user?.id) { + return redirect({ + href: { + pathname: '/login', + query: { + ...(getSessionCookie(requestHeaders) ? { reauth: '1' } : {}), + callbackUrl: `/${locale}/mcp/authorize?code=${encodeURIComponent(code)}`, + }, + }, + locale, + }) + } + + const approvalStatus = await createMcpDeviceLoginApprovalChallenge({ + code, + userId: session.user.id, + }) + + if (approvalStatus.status === 'expired') { + return renderStatus(mcpCopy.expired) + } + + if (approvalStatus.status === 'approved') { + return renderStatus(mcpCopy.approved) + } + + if (approvalStatus.status !== 'pending') { + return renderStatus(mcpCopy.invalid) + } + + return ( +
+ +
+ + + +
+ + +
+
+

+ {mcpCopy.confirm.terminalHint} +

+
+ ) +} diff --git a/apps/tradinggoose/app/[locale]/workspace/page.test.tsx b/apps/tradinggoose/app/[locale]/workspace/page.test.tsx index d302326ec..66acecce3 100644 --- a/apps/tradinggoose/app/[locale]/workspace/page.test.tsx +++ b/apps/tradinggoose/app/[locale]/workspace/page.test.tsx @@ -7,6 +7,7 @@ const mockRedirect = vi.fn((url: string) => { const mockGetSession = vi.fn() const mockHeaders = vi.fn() const mockGetUserWorkspaces = vi.fn() +const mockCreateDefaultWorkspaceForUser = vi.fn() const mockReadWorkflowAccessContext = vi.fn() function mockLocalizedRedirect({ @@ -38,6 +39,7 @@ vi.mock('@/lib/auth', () => ({ })) vi.mock('@/lib/workspaces/service', () => ({ + createDefaultWorkspaceForUser: (...args: unknown[]) => mockCreateDefaultWorkspaceForUser(...args), getUserWorkspaces: (...args: unknown[]) => mockGetUserWorkspaces(...args), })) @@ -72,6 +74,7 @@ describe('Workspace root page access guard', () => { }, }) mockGetUserWorkspaces.mockResolvedValue([{ id: 'workspace-1' }]) + mockCreateDefaultWorkspaceForUser.mockResolvedValue({ id: 'workspace-created' }) mockReadWorkflowAccessContext.mockResolvedValue(null) }) @@ -146,20 +149,16 @@ describe('Workspace root page access guard', () => { expect(mockGetUserWorkspaces).toHaveBeenCalledWith({ userId: 'user-1', - userName: 'Ada Lovelace', }) }) - it('bootstraps a workspace on the server when the user has none and redirects to it', async () => { - mockGetUserWorkspaces.mockResolvedValue([{ id: 'workspace-bootstrapped' }]) + it('repairs authenticated users with no workspace from the workspace entrypoint', async () => { + mockGetUserWorkspaces.mockResolvedValue([]) await expect(renderWorkspacePage('en')).rejects.toThrow( - 'redirect:/en/workspace/workspace-bootstrapped/dashboard' + 'redirect:/en/workspace/workspace-created/dashboard' ) - expect(mockGetUserWorkspaces).toHaveBeenCalledWith({ - userId: 'user-1', - userName: 'Ada Lovelace', - }) + expect(mockCreateDefaultWorkspaceForUser).toHaveBeenCalledWith('user-1', 'Ada Lovelace') }) }) diff --git a/apps/tradinggoose/app/[locale]/workspace/page.tsx b/apps/tradinggoose/app/[locale]/workspace/page.tsx index d4782dbbc..132bd0ee6 100644 --- a/apps/tradinggoose/app/[locale]/workspace/page.tsx +++ b/apps/tradinggoose/app/[locale]/workspace/page.tsx @@ -2,7 +2,7 @@ import { getSessionCookie } from 'better-auth/cookies' import { headers } from 'next/headers' import { getSession } from '@/lib/auth' import { readWorkflowAccessContext } from '@/lib/workflows/utils' -import { getUserWorkspaces } from '@/lib/workspaces/service' +import { createDefaultWorkspaceForUser, getUserWorkspaces } from '@/lib/workspaces/service' import { redirect } from '@/i18n/navigation' import { type LocaleCode, normalizeCallbackUrl, requireCanonicalCallbackPath } from '@/i18n/utils' @@ -87,14 +87,9 @@ export default async function WorkspacePage({ } } - const [workspace] = await getUserWorkspaces({ - userId, - userName: session.user.name, - }) + const [workspace] = await getUserWorkspaces({ userId }) + const targetWorkspace = + workspace ?? (await createDefaultWorkspaceForUser(userId, session.user.name)) - if (!workspace) { - throw new Error('Expected workspace bootstrap to return a workspace') - } - - return redirect({ href: `/workspace/${workspace.id}/dashboard`, locale }) + return redirect({ href: `/workspace/${targetWorkspace.id}/dashboard`, locale }) } diff --git a/apps/tradinggoose/app/api/auth/mcp/authorize/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/authorize/route.test.ts new file mode 100644 index 000000000..13e5974ab --- /dev/null +++ b/apps/tradinggoose/app/api/auth/mcp/authorize/route.test.ts @@ -0,0 +1,174 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockApproveMcpDeviceLogin, + mockCancelMcpDeviceLogin, + mockGetBaseUrl, + mockGetSession, + mockGetSessionCookie, +} = vi.hoisted(() => ({ + mockApproveMcpDeviceLogin: vi.fn(), + mockCancelMcpDeviceLogin: vi.fn(), + mockGetBaseUrl: vi.fn(), + mockGetSession: vi.fn(), + mockGetSessionCookie: vi.fn(), +})) + +vi.mock('better-auth/cookies', () => ({ + getSessionCookie: (...args: unknown[]) => mockGetSessionCookie(...args), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: (...args: unknown[]) => mockGetSession(...args), +})) + +vi.mock('@/lib/mcp/auth', () => ({ + approveMcpDeviceLogin: (...args: unknown[]) => mockApproveMcpDeviceLogin(...args), + cancelMcpDeviceLogin: (...args: unknown[]) => mockCancelMcpDeviceLogin(...args), +})) + +vi.mock('@/lib/urls/utils', () => ({ + getBaseUrl: (...args: unknown[]) => mockGetBaseUrl(...args), +})) + +function createAuthorizeRequest( + body: Record, + headers: Record = {}, + origin = 'https://studio.example.test' +) { + return new NextRequest(`${origin}/api/auth/mcp/authorize`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + origin, + ...headers, + }, + body: new URLSearchParams(body), + }) +} + +describe('MCP authorize route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetBaseUrl.mockReturnValue('https://studio.example.test') + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockGetSessionCookie.mockReturnValue(null) + mockApproveMcpDeviceLogin.mockResolvedValue({ + status: 'approved', + expiresAt: '2026-06-19T12:00:00.000Z', + }) + mockCancelMcpDeviceLogin.mockResolvedValue({ status: 'cancelled' }) + }) + + it('approves a device login from an explicit submitted confirmation', async () => { + const { POST } = await import('./route') + + const response = await POST( + createAuthorizeRequest( + { + action: 'approve', + approvalToken: 'approval-token', + code: 'login-code', + locale: 'es', + }, + { origin: 'https://studio.example.test' }, + 'https://preview.example.test' + ) + ) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'https://studio.example.test/es/mcp/authorize?status=approved' + ) + expect(mockApproveMcpDeviceLogin).toHaveBeenCalledWith({ + approvalToken: 'approval-token', + code: 'login-code', + userId: 'user-1', + }) + expect(mockCancelMcpDeviceLogin).not.toHaveBeenCalled() + }) + + it('cancels a pending device login from an explicit submitted confirmation', async () => { + const { POST } = await import('./route') + + const response = await POST( + createAuthorizeRequest({ + action: 'cancel', + approvalToken: 'approval-token', + code: 'login-code', + locale: 'zh', + }) + ) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'https://studio.example.test/zh/mcp/authorize?status=cancelled' + ) + expect(mockCancelMcpDeviceLogin).toHaveBeenCalledWith({ + approvalToken: 'approval-token', + code: 'login-code', + userId: 'user-1', + }) + expect(mockApproveMcpDeviceLogin).not.toHaveBeenCalled() + }) + + it('rejects malformed confirmation submissions before auth mutation', async () => { + const { POST } = await import('./route') + + const response = await POST(createAuthorizeRequest({ action: 'approve', locale: 'es' })) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'https://studio.example.test/es/mcp/authorize?status=invalid' + ) + expect(mockApproveMcpDeviceLogin).not.toHaveBeenCalled() + expect(mockCancelMcpDeviceLogin).not.toHaveBeenCalled() + }) + + it('rejects approval submissions without the rendered approval token', async () => { + const { POST } = await import('./route') + + const response = await POST( + createAuthorizeRequest({ + action: 'approve', + code: 'login-code', + locale: 'es', + }) + ) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'https://studio.example.test/es/mcp/authorize?status=invalid' + ) + expect(mockApproveMcpDeviceLogin).not.toHaveBeenCalled() + expect(mockCancelMcpDeviceLogin).not.toHaveBeenCalled() + }) + + it('rejects approval submissions from an untrusted origin', async () => { + const { POST } = await import('./route') + + const response = await POST( + createAuthorizeRequest( + { + action: 'approve', + approvalToken: 'approval-token', + code: 'login-code', + locale: 'es', + }, + { origin: 'https://attacker.example.test' } + ) + ) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'https://studio.example.test/es/mcp/authorize?status=invalid' + ) + expect(mockApproveMcpDeviceLogin).not.toHaveBeenCalled() + expect(mockCancelMcpDeviceLogin).not.toHaveBeenCalled() + }) +}) diff --git a/apps/tradinggoose/app/api/auth/mcp/authorize/route.ts b/apps/tradinggoose/app/api/auth/mcp/authorize/route.ts new file mode 100644 index 000000000..f1d715cce --- /dev/null +++ b/apps/tradinggoose/app/api/auth/mcp/authorize/route.ts @@ -0,0 +1,82 @@ +import { getSessionCookie } from 'better-auth/cookies' +import { type NextRequest, NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { approveMcpDeviceLogin, cancelMcpDeviceLogin } from '@/lib/mcp/auth' +import { getBaseUrl } from '@/lib/urls/utils' +import { normalizeLocaleCode } from '@/i18n/utils' + +export const dynamic = 'force-dynamic' + +function redirectToAuthorizeStatus(locale: string, status: string) { + const url = new URL(`/${normalizeLocaleCode(locale)}/mcp/authorize`, getBaseUrl()) + url.searchParams.set('status', status) + return NextResponse.redirect(url) +} + +function redirectToLogin(request: NextRequest, locale: string, code: string) { + const normalizedLocale = normalizeLocaleCode(locale) + const url = new URL(`/${normalizedLocale}/login`, getBaseUrl()) + if (getSessionCookie(request.headers)) { + url.searchParams.set('reauth', '1') + } + url.searchParams.set( + 'callbackUrl', + `/${normalizedLocale}/mcp/authorize?code=${encodeURIComponent(code)}` + ) + return NextResponse.redirect(url) +} + +function hasTrustedFormOrigin(request: NextRequest) { + const trustedOrigin = new URL(getBaseUrl()).origin + const submittedOrigin = request.headers.get('origin') + if (submittedOrigin) { + try { + return new URL(submittedOrigin).origin === trustedOrigin + } catch { + return false + } + } + + const referer = request.headers.get('referer') + if (!referer) { + return false + } + + try { + return new URL(referer).origin === trustedOrigin + } catch { + return false + } +} + +export async function POST(request: NextRequest) { + const formData = await request.formData().catch(() => null) + const action = formData?.get('action') + const code = formData?.get('code') + const approvalToken = formData?.get('approvalToken') + const localeValue = formData?.get('locale') + const locale = normalizeLocaleCode(typeof localeValue === 'string' ? localeValue : undefined) + + if ( + (action !== 'approve' && action !== 'cancel') || + typeof code !== 'string' || + !code || + typeof approvalToken !== 'string' || + !approvalToken || + !hasTrustedFormOrigin(request) + ) { + return redirectToAuthorizeStatus(locale, 'invalid') + } + + const session = await getSession(request.headers) + if (!session?.user?.id) { + return redirectToLogin(request, locale, code) + } + + const result = + action === 'approve' + ? await approveMcpDeviceLogin({ approvalToken, code, userId: session.user.id }) + : await cancelMcpDeviceLogin({ approvalToken, code, userId: session.user.id }) + + return redirectToAuthorizeStatus(locale, result.status) +} diff --git a/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts new file mode 100644 index 000000000..f2239464c --- /dev/null +++ b/apps/tradinggoose/app/api/auth/mcp/poll/route.test.ts @@ -0,0 +1,130 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAcknowledgeMcpDeviceLogin, + mockCheckPublicApiEndpointRateLimit, + mockIsApiKeyStorageAvailable, + mockPollMcpDeviceLogin, +} = vi.hoisted(() => ({ + mockAcknowledgeMcpDeviceLogin: vi.fn(), + mockCheckPublicApiEndpointRateLimit: vi.fn(), + mockIsApiKeyStorageAvailable: vi.fn(), + mockPollMcpDeviceLogin: vi.fn(), +})) + +vi.mock('@/lib/api/rate-limit', () => ({ + checkPublicApiEndpointRateLimit: (...args: unknown[]) => + mockCheckPublicApiEndpointRateLimit(...args), +})) + +vi.mock('@/lib/api-key/service', () => ({ + isApiKeyStorageAvailable: (...args: unknown[]) => mockIsApiKeyStorageAvailable(...args), +})) + +vi.mock('@/lib/mcp/auth', () => ({ + acknowledgeMcpDeviceLogin: (...args: unknown[]) => mockAcknowledgeMcpDeviceLogin(...args), + pollMcpDeviceLogin: (...args: unknown[]) => mockPollMcpDeviceLogin(...args), +})) + +describe('MCP login poll route', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckPublicApiEndpointRateLimit.mockResolvedValue({ + allowed: true, + remaining: 119, + resetAt: new Date('2026-06-19T12:01:00.000Z'), + limit: 120, + }) + mockIsApiKeyStorageAvailable.mockReturnValue(true) + mockPollMcpDeviceLogin.mockResolvedValue({ + status: 'approved', + apiKey: 'sk-tradinggoose-token', + expiresAt: '2026-06-19T12:00:00.000Z', + }) + mockAcknowledgeMcpDeviceLogin.mockResolvedValue({ + status: 'acknowledged', + }) + }) + + it('polls the device login by code and verification key', async () => { + const { POST } = await import('./route') + const request = new NextRequest('https://studio.example.test/api/auth/mcp/poll', { + method: 'POST', + body: JSON.stringify({ code: 'login-code', verificationKey: 'verification-key' }), + }) + + const response = await POST(request) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + status: 'approved', + apiKey: 'sk-tradinggoose-token', + expiresAt: '2026-06-19T12:00:00.000Z', + }) + expect(mockCheckPublicApiEndpointRateLimit).toHaveBeenCalledWith(request, 'mcp-auth-poll') + expect(mockPollMcpDeviceLogin).toHaveBeenCalledWith('login-code', 'verification-key') + expect(mockAcknowledgeMcpDeviceLogin).not.toHaveBeenCalled() + }) + + it('acknowledges a locally persisted device login token', async () => { + const { POST } = await import('./route') + const request = new NextRequest('https://studio.example.test/api/auth/mcp/poll', { + method: 'POST', + body: JSON.stringify({ + code: 'login-code', + verificationKey: 'verification-key', + ackApiKey: 'sk-tradinggoose-token', + }), + }) + + const response = await POST(request) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ status: 'acknowledged' }) + expect(mockAcknowledgeMcpDeviceLogin).toHaveBeenCalledWith({ + apiKey: 'sk-tradinggoose-token', + code: 'login-code', + verificationKey: 'verification-key', + }) + expect(mockPollMcpDeviceLogin).not.toHaveBeenCalled() + }) + + it('rejects malformed poll requests', async () => { + const { POST } = await import('./route') + + const response = await POST( + new NextRequest('https://studio.example.test/api/auth/mcp/poll', { + method: 'POST', + body: JSON.stringify({}), + }) + ) + + expect(response.status).toBe(400) + expect(mockPollMcpDeviceLogin).not.toHaveBeenCalled() + }) + + it('rejects polls when the public endpoint rate limit is exhausted', async () => { + mockCheckPublicApiEndpointRateLimit.mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: new Date('2026-06-19T12:01:00.000Z'), + limit: 120, + }) + const { POST } = await import('./route') + + const response = await POST( + new NextRequest('https://studio.example.test/api/auth/mcp/poll', { + method: 'POST', + body: JSON.stringify({ code: 'login-code', verificationKey: 'verification-key' }), + }) + ) + + expect(response.status).toBe(429) + expect(mockPollMcpDeviceLogin).not.toHaveBeenCalled() + }) +}) diff --git a/apps/tradinggoose/app/api/auth/mcp/poll/route.ts b/apps/tradinggoose/app/api/auth/mcp/poll/route.ts new file mode 100644 index 000000000..019b61ca2 --- /dev/null +++ b/apps/tradinggoose/app/api/auth/mcp/poll/route.ts @@ -0,0 +1,42 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkPublicApiEndpointRateLimit } from '@/lib/api/rate-limit' +import { isApiKeyStorageAvailable } from '@/lib/api-key/service' +import { acknowledgeMcpDeviceLogin, pollMcpDeviceLogin } from '@/lib/mcp/auth' + +export const dynamic = 'force-dynamic' + +const PollRequestSchema = z + .object({ + code: z.string().min(1), + verificationKey: z.string().min(1), + ackApiKey: z.string().min(1).optional(), + }) + .strict() + +export async function POST(request: NextRequest) { + const rateLimit = await checkPublicApiEndpointRateLimit(request, 'mcp-auth-poll') + if (!rateLimit.allowed) { + const status = rateLimit.failureKind === 'dependency' ? 503 : 429 + return NextResponse.json({ error: rateLimit.error || 'Rate limit exceeded' }, { status }) + } + + const parsed = PollRequestSchema.safeParse(await request.json().catch(() => null)) + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid MCP login poll request' }, { status: 400 }) + } + + if (!isApiKeyStorageAvailable()) { + return NextResponse.json({ error: 'API key access is not configured' }, { status: 503 }) + } + + const result = + parsed.data.ackApiKey !== undefined + ? await acknowledgeMcpDeviceLogin({ + apiKey: parsed.data.ackApiKey, + code: parsed.data.code, + verificationKey: parsed.data.verificationKey, + }) + : await pollMcpDeviceLogin(parsed.data.code, parsed.data.verificationKey) + return NextResponse.json(result) +} diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts new file mode 100644 index 000000000..2ac1dede4 --- /dev/null +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.test.ts @@ -0,0 +1,90 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckPublicApiEndpointRateLimit, + mockIsApiKeyStorageAvailable, + mockStartMcpDeviceLogin, +} = vi.hoisted(() => ({ + mockCheckPublicApiEndpointRateLimit: vi.fn(), + mockIsApiKeyStorageAvailable: vi.fn(), + mockStartMcpDeviceLogin: vi.fn(), +})) + +vi.mock('@/lib/api/rate-limit', () => ({ + checkPublicApiEndpointRateLimit: (...args: unknown[]) => + mockCheckPublicApiEndpointRateLimit(...args), +})) + +vi.mock('@/lib/api-key/service', () => ({ + isApiKeyStorageAvailable: (...args: unknown[]) => mockIsApiKeyStorageAvailable(...args), +})) + +vi.mock('@/lib/mcp/auth', () => ({ + startMcpDeviceLogin: (...args: unknown[]) => mockStartMcpDeviceLogin(...args), +})) + +describe('MCP login start route', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://studio.example.test') + mockCheckPublicApiEndpointRateLimit.mockResolvedValue({ + allowed: true, + remaining: 19, + resetAt: new Date('2026-06-19T12:01:00.000Z'), + limit: 20, + }) + mockIsApiKeyStorageAvailable.mockReturnValue(true) + mockStartMcpDeviceLogin.mockResolvedValue({ + code: 'login-code', + verificationKey: 'verification-key', + expiresAt: '2026-06-19T12:00:00.000Z', + intervalSeconds: 2, + }) + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('starts a browser approval login and returns an absolute approval URL', async () => { + const { POST } = await import('./route') + const request = new NextRequest('https://preview.example.test/api/auth/mcp/start', { + method: 'POST', + }) + + const response = await POST(request) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + code: 'login-code', + verificationKey: 'verification-key', + expiresAt: '2026-06-19T12:00:00.000Z', + intervalSeconds: 2, + authorizeUrl: 'https://studio.example.test/mcp/authorize?code=login-code', + }) + expect(mockCheckPublicApiEndpointRateLimit).toHaveBeenCalledWith(request, 'mcp-auth-start') + expect(mockStartMcpDeviceLogin).toHaveBeenCalledWith() + }) + + it('rejects login starts when the public endpoint rate limit is exhausted', async () => { + mockCheckPublicApiEndpointRateLimit.mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: new Date('2026-06-19T12:01:00.000Z'), + limit: 20, + }) + const { POST } = await import('./route') + + const response = await POST( + new NextRequest('https://studio.example.test/api/auth/mcp/start', { method: 'POST' }) + ) + + expect(response.status).toBe(429) + expect(mockStartMcpDeviceLogin).not.toHaveBeenCalled() + }) +}) diff --git a/apps/tradinggoose/app/api/auth/mcp/start/route.ts b/apps/tradinggoose/app/api/auth/mcp/start/route.ts new file mode 100644 index 000000000..54b53299a --- /dev/null +++ b/apps/tradinggoose/app/api/auth/mcp/start/route.ts @@ -0,0 +1,28 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { checkPublicApiEndpointRateLimit } from '@/lib/api/rate-limit' +import { isApiKeyStorageAvailable } from '@/lib/api-key/service' +import { startMcpDeviceLogin } from '@/lib/mcp/auth' +import { getBaseUrl } from '@/lib/urls/utils' + +export const dynamic = 'force-dynamic' + +export async function POST(request: NextRequest) { + const rateLimit = await checkPublicApiEndpointRateLimit(request, 'mcp-auth-start') + if (!rateLimit.allowed) { + const status = rateLimit.failureKind === 'dependency' ? 503 : 429 + return NextResponse.json({ error: rateLimit.error || 'Rate limit exceeded' }, { status }) + } + if (!isApiKeyStorageAvailable()) { + return NextResponse.json({ error: 'API key access is not configured' }, { status: 503 }) + } + + const baseUrl = getBaseUrl() + const login = await startMcpDeviceLogin() + const authorizeUrl = new URL('/mcp/authorize', baseUrl) + authorizeUrl.searchParams.set('code', login.code) + + return NextResponse.json({ + ...login, + authorizeUrl: authorizeUrl.toString(), + }) +} diff --git a/apps/tradinggoose/app/api/chat/[identifier]/route.test.ts b/apps/tradinggoose/app/api/chat/[identifier]/route.test.ts index 33b677036..41b3fec77 100644 --- a/apps/tradinggoose/app/api/chat/[identifier]/route.test.ts +++ b/apps/tradinggoose/app/api/chat/[identifier]/route.test.ts @@ -50,7 +50,6 @@ vi.mock('@tradinggoose/db/schema', () => ({ id: 'workflow.id', isDeployed: 'workflow.isDeployed', workspaceId: 'workflow.workspaceId', - variables: 'workflow.variables', pinnedApiKeyId: 'workflow.pinnedApiKeyId', }, })) @@ -126,6 +125,8 @@ vi.mock('@/lib/utils', () => ({ }, })) +import { GET, POST } from './route' + const chatParams = () => ({ params: Promise.resolve({ identifier: 'test-chat' }) }) const postChatRequest = (body: Record) => new NextRequest('https://example.com/api/chat/test-chat', { @@ -235,7 +236,6 @@ describe('/api/chat/[identifier]', () => { }) it('returns chat metadata for a valid identifier', async () => { - const { GET } = await import('./route') const response = await GET( new NextRequest('https://example.com/api/chat/test-chat'), chatParams() @@ -250,7 +250,6 @@ describe('/api/chat/[identifier]', () => { }) it('queues chat workflow messages and returns an SSE response from queued result', async () => { - const { POST } = await import('./route') const response = await POST( postChatRequest({ input: 'Hello', @@ -281,6 +280,9 @@ describe('/api/chat/[identifier]', () => { }), }) ) + expect(enqueuePendingExecutionMock.mock.calls[0]?.[0].payload).not.toHaveProperty( + 'workflowVariables' + ) const body = await response.text() @@ -304,7 +306,6 @@ describe('/api/chat/[identifier]', () => { }) try { - const { POST } = await import('./route') const response = await POST( postChatRequest({ input: 'Hello', @@ -330,7 +331,6 @@ describe('/api/chat/[identifier]', () => { it('requires a pinned API key owner for queued chat execution attribution', async () => { getApiKeyOwnerUserIdMock.mockResolvedValueOnce(null) - const { POST } = await import('./route') const response = await POST(postChatRequest({ input: 'Hello' }), chatParams()) expect(response.status).toBe(503) diff --git a/apps/tradinggoose/app/api/chat/[identifier]/route.ts b/apps/tradinggoose/app/api/chat/[identifier]/route.ts index 00e9ae4d2..cd290436f 100644 --- a/apps/tradinggoose/app/api/chat/[identifier]/route.ts +++ b/apps/tradinggoose/app/api/chat/[identifier]/route.ts @@ -14,7 +14,6 @@ import { ChatFiles } from '@/lib/uploads' import { encodeSSE, generateRequestId, SSE_HEADERS } from '@/lib/utils' import { createChatOutputEventReader } from '@/lib/workflows/chat-output' import type { WorkflowExecutionEventEntry } from '@/lib/workflows/execution-events' -import { CHAT_ERROR_CODES } from '@/app/chat/constants' import { addCorsHeaders, setChatAuthCookie, @@ -22,6 +21,7 @@ import { validateChatAuth, } from '@/app/api/chat/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { CHAT_ERROR_CODES } from '@/app/chat/constants' const logger = createLogger('ChatIdentifierAPI') @@ -55,14 +55,14 @@ export async function POST( // Parse the request body once let parsedBody - try { - parsedBody = await request.json() - } catch (_error) { - return addCorsHeaders( - createErrorResponse('Invalid request body', 400, CHAT_ERROR_CODES.INVALID_REQUEST_BODY), - request - ) - } + try { + parsedBody = await request.json() + } catch (_error) { + return addCorsHeaders( + createErrorResponse('Invalid request body', 400, CHAT_ERROR_CODES.INVALID_REQUEST_BODY), + request + ) + } // Find the chat deployment for this identifier const deploymentResult = await db @@ -143,7 +143,6 @@ export async function POST( .select({ isDeployed: workflow.isDeployed, workspaceId: workflow.workspaceId, - variables: workflow.variables, pinnedApiKeyId: workflow.pinnedApiKeyId, }) .from(workflow) @@ -240,10 +239,6 @@ export async function POST( executionTarget: 'deployed', stream: true, selectedOutputs, - workflowVariables: - workflowResult[0].variables && typeof workflowResult[0].variables === 'object' - ? (workflowResult[0].variables as Record) - : undefined, metadata: { source: 'published_chat', chatId: deployment.id, diff --git a/apps/tradinggoose/app/api/copilot/chat/review-session-post.test.ts b/apps/tradinggoose/app/api/copilot/chat/review-session-post.test.ts index 0947718e7..95c103709 100644 --- a/apps/tradinggoose/app/api/copilot/chat/review-session-post.test.ts +++ b/apps/tradinggoose/app/api/copilot/chat/review-session-post.test.ts @@ -13,6 +13,7 @@ describe('Copilot Chat POST Generic Sessions', () => { const mockLoadReviewSessionForUser = vi.fn() const mockProxyCopilotRequest = vi.fn() const mockProcessContextsServer = vi.fn() + const mockMirrorLocalCopilotCompletionUsageReports = vi.fn() const mockBuildAppendReviewTurn = vi.fn(() => ({ turn: { id: 'turn-1', @@ -205,6 +206,10 @@ describe('Copilot Chat POST Generic Sessions', () => { })), })) + vi.doMock('@/lib/copilot/completion-usage-billing', () => ({ + mirrorLocalCopilotCompletionUsageReports: mockMirrorLocalCopilotCompletionUsageReports, + })) + vi.doMock('@/lib/copilot/agent/utils', () => ({ requestCopilotTitle: vi.fn().mockResolvedValue(null), })) @@ -228,7 +233,14 @@ describe('Copilot Chat POST Generic Sessions', () => { })) vi.doMock('@/lib/copilot/review-sessions/types', () => ({ - REVIEW_ENTITY_KINDS: ['workflow', 'skill', 'custom_tool', 'mcp_server', 'indicator'], + REVIEW_ENTITY_KINDS: [ + 'workflow', + 'skill', + 'custom_tool', + 'mcp_server', + 'indicator', + 'knowledge_base', + ], })) vi.doMock('@/lib/copilot/runtime-provider.server', () => ({ @@ -290,6 +302,13 @@ describe('Copilot Chat POST Generic Sessions', () => { vi.doMock('@/lib/copilot/process-contents', () => ({ processContextsServer: mockProcessContextsServer, })) + + vi.doMock('@/lib/copilot/runtime-tool-manifest', () => ({ + getCopilotRuntimeToolManifest: vi.fn().mockResolvedValue({ + version: 'v1', + tools: [{ name: 'read_workflow' }, { name: 'edit_workflow' }], + }), + })) }) afterEach(() => { diff --git a/apps/tradinggoose/app/api/copilot/chat/review-session.test.ts b/apps/tradinggoose/app/api/copilot/chat/review-session.test.ts index 8ff3d0832..f157c3836 100644 --- a/apps/tradinggoose/app/api/copilot/chat/review-session.test.ts +++ b/apps/tradinggoose/app/api/copilot/chat/review-session.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { setupCommonApiMocks } from '@/app/api/__test-utils__/utils' describe('Copilot Chat Review Session GET', () => { + let GET: typeof import('@/app/api/copilot/chat/route').GET const mockSelect = vi.fn() const mockFromSessions = vi.fn() const mockWhereSessions = vi.fn() @@ -20,7 +21,7 @@ describe('Copilot Chat Review Session GET', () => { const mockMapReviewItemToApi = vi.fn() - beforeEach(() => { + beforeEach(async () => { vi.resetModules() setupCommonApiMocks() @@ -177,6 +178,14 @@ describe('Copilot Chat Review Session GET', () => { getCopilotModel: vi.fn(), })) + vi.doMock('@/lib/copilot/completion-usage-billing', () => ({ + mirrorLocalCopilotCompletionUsageReports: vi.fn().mockResolvedValue(undefined), + })) + + vi.doMock('@/lib/copilot/runtime-provider.server', () => ({ + buildCopilotRuntimeProviderConfig: vi.fn(), + })) + vi.doMock('@/lib/copilot/review-sessions/thread-history', () => ({ buildAppendReviewTurn: vi.fn(), MESSAGE_ROLES: { @@ -208,24 +217,33 @@ describe('Copilot Chat Review Session GET', () => { createdAt: 'createdAt', updatedAt: 'updatedAt', }, - mapSessionToApiResponse: vi.fn((session: any, opts: { messageCount: number; messages?: any[] }) => ({ - reviewSessionId: session.id, - workspaceId: session.workspaceId, - entityKind: session.entityKind, - entityId: session.entityId, - draftSessionId: session.draftSessionId, - title: session.title, - messages: opts.messages ?? [], - messageCount: opts.messageCount, - conversationId: session.conversationId, - createdAt: session.createdAt, - updatedAt: session.updatedAt, - })), + mapSessionToApiResponse: vi.fn( + (session: any, opts: { messageCount: number; messages?: any[] }) => ({ + reviewSessionId: session.id, + workspaceId: session.workspaceId, + entityKind: session.entityKind, + entityId: session.entityId, + draftSessionId: session.draftSessionId, + title: session.title, + messages: opts.messages ?? [], + messageCount: opts.messageCount, + conversationId: session.conversationId, + createdAt: session.createdAt, + updatedAt: session.updatedAt, + }) + ), })) vi.doMock('@/lib/copilot/review-sessions/types', () => ({ ENTITY_KIND_WORKFLOW: 'workflow', - REVIEW_ENTITY_KINDS: ['workflow', 'skill', 'custom_tool', 'mcp_server', 'indicator'], + REVIEW_ENTITY_KINDS: [ + 'workflow', + 'skill', + 'custom_tool', + 'mcp_server', + 'indicator', + 'knowledge_base', + ], })) vi.doMock('@/lib/logs/console/logger', () => ({ @@ -251,6 +269,7 @@ describe('Copilot Chat Review Session GET', () => { proxyCopilotRequest: vi.fn(), })) + ;({ GET } = await import('@/app/api/copilot/chat/route')) }) afterEach(() => { @@ -263,7 +282,6 @@ describe('Copilot Chat Review Session GET', () => { 'http://localhost:3000/api/copilot/chat?reviewSessionId=review-session-1' ) - const { GET } = await import('@/app/api/copilot/chat/route') const response = await GET(request) expect(response.status).toBe(200) @@ -325,7 +343,6 @@ describe('Copilot Chat Review Session GET', () => { 'http://localhost:3000/api/copilot/chat?reviewSessionId=entity-review-session-1' ) - const { GET } = await import('@/app/api/copilot/chat/route') const response = await GET(request) expect(response.status).toBe(404) @@ -346,7 +363,6 @@ describe('Copilot Chat Review Session GET', () => { 'http://localhost:3000/api/copilot/chat?workspaceId=workspace-1' ) - const { GET } = await import('@/app/api/copilot/chat/route') const response = await GET(request) expect(response.status).toBe(200) @@ -385,5 +401,4 @@ describe('Copilot Chat Review Session GET', () => { expect(mockSelect).toHaveBeenCalledTimes(3) }) - }) diff --git a/apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts b/apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts index de79c7705..7ca7b584c 100644 --- a/apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts +++ b/apps/tradinggoose/app/api/copilot/execute-copilot-server-tool/route.ts @@ -13,17 +13,32 @@ import { checkWorkspaceAccess } from '@/lib/permissions/utils' const logger = createLogger('ExecuteCopilotServerToolAPI') -const ExecuteSchema = z.object({ - toolName: z.string().min(1), - payload: z.unknown().optional(), - context: z - .object({ - contextEntityKind: z.enum(REVIEW_ENTITY_KINDS).optional(), - contextEntityId: z.string().optional(), - workspaceId: z.string().optional(), - }) - .optional(), -}) +const ExecuteSchema = z + .object({ + toolName: z.string().min(1), + payload: z.unknown().optional(), + reviewAction: z.enum(['accept']).optional(), + reviewToken: z.string().optional(), + context: z + .object({ + contextEntityKind: z.enum(REVIEW_ENTITY_KINDS).optional(), + contextEntityId: z.string().optional(), + workspaceId: z.string().optional(), + }) + .optional(), + }) + .strict() + +function readPayloadWorkspaceId(payload: unknown): string | undefined { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return undefined + } + + const workspaceId = (payload as { workspaceId?: unknown }).workspaceId + return typeof workspaceId === 'string' && workspaceId.trim().length > 0 + ? workspaceId.trim() + : undefined +} export async function POST(req: NextRequest) { const tracker = createRequestTracker() @@ -35,11 +50,6 @@ export async function POST(req: NextRequest) { } const body = await req.json() - try { - const preview = JSON.stringify(body).slice(0, 300) - logger.debug(`[${tracker.requestId}] Incoming request body preview`, { preview }) - } catch {} - let parsedBody: z.infer try { parsedBody = ExecuteSchema.parse(body) @@ -53,20 +63,40 @@ export async function POST(req: NextRequest) { throw error } toolName = parsedBody.toolName - const { payload, context } = parsedBody + const { payload, context, reviewAction, reviewToken } = parsedBody + if (reviewAction === 'accept' && !reviewToken) { + return createBadRequestResponse('reviewToken is required to accept a server tool review') + } + const payloadWorkspaceId = readPayloadWorkspaceId(payload) + const contextWorkspaceId = context?.workspaceId?.trim() - const [{ isToolId }, { routeExecution }] = await Promise.all([ + if (payloadWorkspaceId && contextWorkspaceId && payloadWorkspaceId !== contextWorkspaceId) { + return createBadRequestResponse('workspaceId does not match execution context') + } + + const executionContextInput = + payloadWorkspaceId && !contextWorkspaceId + ? { ...(context ?? {}), workspaceId: payloadWorkspaceId } + : context + + const [ + { isToolId }, + { routeExecution }, + { acceptServerManagedToolReview, stageServerManagedToolReview }, + ] = await Promise.all([ import('@/lib/copilot/registry'), import('@/lib/copilot/tools/server/router'), + import('@/lib/copilot/tools/server/review-acceptance'), ]) if (!isToolId(toolName)) { return createBadRequestResponse('Invalid request body for execute-copilot-server-tool') } + const toolId = toolName - logger.info(`[${tracker.requestId}] Executing server tool`, { toolName }) - if (context?.workspaceId) { - const workspaceAccess = await checkWorkspaceAccess(context.workspaceId, userId) + logger.info(`[${tracker.requestId}] Executing server tool`, { toolName: toolId, reviewAction }) + if (executionContextInput?.workspaceId) { + const workspaceAccess = await checkWorkspaceAccess(executionContextInput.workspaceId, userId) if (!workspaceAccess.exists || !workspaceAccess.hasAccess) { return NextResponse.json( { error: 'Access denied to this workspace', code: 'WORKSPACE_ACCESS_DENIED' }, @@ -75,16 +105,17 @@ export async function POST(req: NextRequest) { } } - const result = await routeExecution(toolName, payload, { + const executionContext = { userId, - ...context, + accessLevel: 'limited' as const, + ...executionContextInput, signal: req.signal, - }) - - try { - const resultPreview = JSON.stringify(result).slice(0, 300) - logger.debug(`[${tracker.requestId}] Server tool result preview`, { toolName, resultPreview }) - } catch {} + } + const result = await (reviewAction === 'accept' + ? acceptServerManagedToolReview(toolId, reviewToken!, executionContext) + : routeExecution(toolId, payload, executionContext).then((toolResult) => + stageServerManagedToolReview(toolId, payload, toolResult, executionContext) + )) return NextResponse.json({ success: true, result }) } catch (error) { diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.test.ts b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts new file mode 100644 index 000000000..77733d2d7 --- /dev/null +++ b/apps/tradinggoose/app/api/copilot/mcp/route.test.ts @@ -0,0 +1,504 @@ +/** + * @vitest-environment node + */ + +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAuthenticateApiKeyFromHeader, + mockCheckApiEndpointRateLimit, + mockCheckPublicApiEndpointRateLimit, + mockCreateDefaultWorkspaceForUser, + mockGetCopilotRuntimeToolManifest, + mockGetMcpServerToolIds, + mockGetUserWorkspaces, + mockRouteExecution, + mockUpdateApiKeyLastUsed, +} = vi.hoisted(() => ({ + mockAuthenticateApiKeyFromHeader: vi.fn(), + mockCheckApiEndpointRateLimit: vi.fn(), + mockCheckPublicApiEndpointRateLimit: vi.fn(), + mockCreateDefaultWorkspaceForUser: vi.fn(), + mockGetCopilotRuntimeToolManifest: vi.fn(), + mockGetMcpServerToolIds: vi.fn(), + mockGetUserWorkspaces: vi.fn(), + mockRouteExecution: vi.fn(), + mockUpdateApiKeyLastUsed: vi.fn(), +})) + +vi.mock('@/lib/api/rate-limit', () => ({ + checkApiEndpointRateLimit: (...args: unknown[]) => mockCheckApiEndpointRateLimit(...args), + checkPublicApiEndpointRateLimit: (...args: unknown[]) => + mockCheckPublicApiEndpointRateLimit(...args), +})) + +vi.mock('@/lib/api-key/service', () => ({ + authenticateApiKeyFromHeader: (...args: unknown[]) => mockAuthenticateApiKeyFromHeader(...args), + updateApiKeyLastUsed: (...args: unknown[]) => mockUpdateApiKeyLastUsed(...args), +})) + +vi.mock('@/lib/copilot/runtime-tool-manifest', () => ({ + getCopilotRuntimeToolManifest: (...args: unknown[]) => mockGetCopilotRuntimeToolManifest(...args), +})) + +vi.mock('@/lib/copilot/tools/server/router', () => ({ + getMcpServerToolIds: (...args: unknown[]) => mockGetMcpServerToolIds(...args), + routeExecution: (...args: unknown[]) => mockRouteExecution(...args), +})) + +vi.mock('@/lib/workspaces/service', () => ({ + createDefaultWorkspaceForUser: (...args: unknown[]) => mockCreateDefaultWorkspaceForUser(...args), + getUserWorkspaces: (...args: unknown[]) => mockGetUserWorkspaces(...args), +})) + +function createMcpRequest( + body: unknown, + authorization = 'Bearer sk-tradinggoose-test', + headers: Record = {} +) { + return new NextRequest('https://studio.example.test/api/copilot/mcp', { + method: 'POST', + headers: { + authorization, + 'content-type': 'application/json', + ...headers, + }, + body: JSON.stringify(body), + }) +} + +function initializeRequest(id: string | number = 1, protocolVersion = '2025-06-18') { + return { + jsonrpc: '2.0', + id, + method: 'initialize', + params: { protocolVersion }, + } +} + +describe('Copilot MCP route', () => { + beforeEach(() => { + vi.resetAllMocks() + mockAuthenticateApiKeyFromHeader.mockResolvedValue({ + success: true, + userId: 'user-1', + keyId: 'key-1', + }) + mockCheckApiEndpointRateLimit.mockResolvedValue({ + allowed: true, + remaining: 99, + limit: 100, + resetAt: new Date('2026-06-24T12:01:00.000Z'), + userId: 'user-1', + }) + mockCheckPublicApiEndpointRateLimit.mockResolvedValue({ + allowed: true, + remaining: 299, + limit: 300, + resetAt: new Date('2026-06-24T12:01:00.000Z'), + }) + mockGetUserWorkspaces.mockResolvedValue([ + { id: 'workspace-1', name: 'Research', permissions: 'admin' }, + { id: 'workspace-2', name: 'Ops', permissions: 'read' }, + ]) + mockCreateDefaultWorkspaceForUser.mockResolvedValue({ + id: 'workspace-created', + name: 'My Workspace', + permissions: 'admin', + }) + mockGetMcpServerToolIds.mockReturnValue(['list_workflows', 'read_workflow']) + mockGetCopilotRuntimeToolManifest.mockResolvedValue({ + version: 'v1', + tools: [ + { + name: 'list_workflows', + description: 'List workflows.', + parameters: { type: 'object', properties: { workspaceId: { type: 'string' } } }, + }, + { + name: 'plan', + description: 'Client-only planning tool.', + parameters: { type: 'object', properties: {} }, + }, + { + name: 'make_api_request', + description: 'Make an HTTP request.', + parameters: { type: 'object', properties: { url: { type: 'string' } } }, + }, + ], + }) + mockRouteExecution.mockResolvedValue({ workflows: [] }) + }) + + it('rejects requests without bearer auth', async () => { + const { POST } = await import('./route') + + const response = await POST(createMcpRequest(initializeRequest(), '')) + const body = await response.json() + + expect(response.status).toBe(401) + expect(body.error.message).toBe('Bearer token required') + expect(mockAuthenticateApiKeyFromHeader).not.toHaveBeenCalled() + }) + + it('returns initialize metadata with authenticated workspace context', async () => { + const { POST } = await import('./route') + + const response = await POST(createMcpRequest(initializeRequest())) + const body = await response.json() + + expect(response.headers.get('MCP-Protocol-Version')).toBe('2025-06-18') + expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledWith('sk-tradinggoose-test', { + keyTypes: ['personal'], + }) + expect(mockUpdateApiKeyLastUsed).toHaveBeenCalledWith('key-1') + expect(mockCheckApiEndpointRateLimit).toHaveBeenCalledWith('user-1', 'copilot-mcp') + expect(mockGetUserWorkspaces).toHaveBeenCalledWith({ userId: 'user-1' }) + expect(body.result.capabilities).toEqual({ tools: {} }) + expect(body.result.serverInfo).toEqual({ name: 'TradingGoose', version: '0.1.0' }) + expect(body.result.instructions).toContain('workspaceId=workspace-1, permissions=admin') + expect(body.result.instructions).toContain('workspaceId=workspace-2, permissions=read') + expect(body.result.instructions).toContain( + 'Do not store workspaceId, entityId, or entity targets' + ) + expect(body.result.instructions).toContain('trusted personal coding agents') + expect(body.result.instructions).toContain('Mutating tools execute directly') + expect(body.result.instructions).toContain('authenticated MCP key') + expect(body.result.instructions).not.toContain('No accessible workspaces') + }) + + it('keeps older supported MCP protocol negotiation internally consistent', async () => { + const { POST } = await import('./route') + + const response = await POST(createMcpRequest(initializeRequest(2, '2025-03-26'))) + const body = await response.json() + + expect(response.headers.get('MCP-Protocol-Version')).toBe('2025-03-26') + expect(body.result.protocolVersion).toBe('2025-03-26') + }) + + it('repairs workspace-less authenticated users during initialize', async () => { + const { POST } = await import('./route') + mockGetUserWorkspaces.mockResolvedValueOnce([]) + + const response = await POST(createMcpRequest(initializeRequest())) + const body = await response.json() + + expect(response.status).toBe(200) + expect(mockCreateDefaultWorkspaceForUser).toHaveBeenCalledWith('user-1') + expect(body.result.instructions).toContain('workspaceId=workspace-created, permissions=admin') + }) + + it('accepts a case-insensitive bearer auth scheme', async () => { + const { POST } = await import('./route') + + const response = await POST(createMcpRequest(initializeRequest(), 'bearer sk-lowercase')) + + expect(response.status).toBe(200) + expect(mockAuthenticateApiKeyFromHeader).toHaveBeenCalledWith('sk-lowercase', { + keyTypes: ['personal'], + }) + }) + + it('lists only executable server copilot tools', async () => { + const { POST } = await import('./route') + + const response = await POST(createMcpRequest({ jsonrpc: '2.0', id: 2, method: 'tools/list' })) + const body = await response.json() + + expect(response.headers.get('MCP-Protocol-Version')).toBe('2025-03-26') + expect(body.result.tools).toEqual([ + { + name: 'list_workflows', + description: 'List workflows.', + inputSchema: { type: 'object', properties: { workspaceId: { type: 'string' } } }, + }, + ]) + }) + + it('returns MCP rate-limit errors from the shared API limiter', async () => { + const { POST } = await import('./route') + mockCheckApiEndpointRateLimit.mockResolvedValueOnce({ + allowed: false, + remaining: 0, + limit: 10, + resetAt: new Date('2026-06-24T12:01:00.000Z'), + userId: 'user-1', + }) + + const response = await POST(createMcpRequest({ jsonrpc: '2.0', id: 2, method: 'tools/list' })) + const body = await response.json() + + expect(response.status).toBe(429) + expect(response.headers.get('X-RateLimit-Limit')).toBe('10') + expect(response.headers.get('Retry-After')).toBeTruthy() + expect(body.error.message).toBe('Rate limit exceeded') + expect(mockGetCopilotRuntimeToolManifest).not.toHaveBeenCalled() + }) + + it('applies the public MCP rate limit before API-key authentication', async () => { + const { POST } = await import('./route') + mockCheckPublicApiEndpointRateLimit.mockResolvedValueOnce({ + allowed: false, + remaining: 0, + limit: 300, + resetAt: new Date('2026-06-24T12:01:00.000Z'), + }) + + const response = await POST(createMcpRequest({ jsonrpc: '2.0', id: 2, method: 'tools/list' })) + + expect(response.status).toBe(429) + expect(mockAuthenticateApiKeyFromHeader).not.toHaveBeenCalled() + expect(mockCheckApiEndpointRateLimit).not.toHaveBeenCalled() + }) + + it('rejects tools outside the external MCP allow-list', async () => { + const { POST } = await import('./route') + + const response = await POST( + createMcpRequest({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'make_api_request', + arguments: { url: 'https://example.test', method: 'GET' }, + }, + }) + ) + const body = await response.json() + + expect(body.error.message).toBe('Unsupported MCP tool: make_api_request') + expect(mockRouteExecution).not.toHaveBeenCalled() + }) + + it('dispatches tool calls through the server tool router', async () => { + const { POST } = await import('./route') + + const response = await POST( + createMcpRequest({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { + name: 'list_workflows', + arguments: { workspaceId: 'workspace-1' }, + }, + }) + ) + const body = await response.json() + + expect(mockRouteExecution).toHaveBeenCalledWith( + 'list_workflows', + { workspaceId: 'workspace-1' }, + { userId: 'user-1', accessLevel: 'full' } + ) + expect(body.result.structuredContent).toEqual({ workflows: [] }) + expect(body.result.content[0].text).toBe(JSON.stringify({ workflows: [] }, null, 2)) + }) + + it('dispatches external MCP mutation tools with full personal-agent access', async () => { + const { POST } = await import('./route') + mockGetMcpServerToolIds.mockReturnValueOnce(['edit_workflow']) + mockRouteExecution.mockResolvedValueOnce({ success: true }) + + const response = await POST( + createMcpRequest({ + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'edit_workflow', + arguments: { workflowId: 'workflow-1', mermaid: 'graph TD' }, + }, + }) + ) + const body = await response.json() + + expect(mockRouteExecution).toHaveBeenCalledWith( + 'edit_workflow', + { workflowId: 'workflow-1', mermaid: 'graph TD' }, + { userId: 'user-1', accessLevel: 'full' } + ) + expect(body.result.structuredContent).toEqual({ success: true }) + }) + + it('returns a sanitized tool result when a tool execution fails', async () => { + const { POST } = await import('./route') + mockGetMcpServerToolIds.mockReturnValue(['list_workflows']) + mockRouteExecution.mockRejectedValueOnce(new Error('connection refused at db.internal:5432')) + + const response = await POST( + createMcpRequest({ + jsonrpc: '2.0', + id: 6, + method: 'tools/call', + params: { name: 'list_workflows', arguments: {} }, + }) + ) + const body = await response.json() + + expect(body.error).toBeUndefined() + expect(body.result.isError).toBe(true) + expect(body.result.structuredContent.code).toBe('server_tool_execution_failed') + expect(body.result.structuredContent.error).toBe('Server tool execution failed') + expect(body.result.content[0].text).not.toContain('db.internal') + }) + + it('sanitizes errors thrown by non-tool methods instead of leaking a raw response', async () => { + const { POST } = await import('./route') + mockGetUserWorkspaces.mockRejectedValueOnce(new Error('workspace bootstrap failed at shard-3')) + + const response = await POST(createMcpRequest(initializeRequest(7))) + const body = await response.json() + + expect(body.error.code).toBe(-32603) + expect(body.error.data.code).toBe('server_tool_execution_failed') + expect(body.error.message).toBe('Server tool execution failed') + expect(JSON.stringify(body)).not.toContain('shard-3') + }) + + it('enforces JSON-RPC and MCP initialize request shape', async () => { + const { POST } = await import('./route') + + const invalidJsonRpcResponse = await POST( + createMcpRequest({ jsonrpc: '1.0', id: 8, method: 'ping' }) + ) + const nullIdResponse = await POST( + createMcpRequest({ jsonrpc: '2.0', id: null, method: 'ping' }) + ) + const invalidInitializeResponse = await POST( + createMcpRequest({ jsonrpc: '2.0', id: 9, method: 'initialize', params: {} }) + ) + const unsupportedVersionResponse = await POST(createMcpRequest(initializeRequest(10, '1.0'))) + const notificationResponse = await POST( + createMcpRequest({ jsonrpc: '2.0', method: 'notifications/initialized', params: {} }) + ) + const negotiatedProtocolHeaderResponse = await POST( + createMcpRequest( + { jsonrpc: '2.0', id: 11, method: 'tools/list' }, + 'Bearer sk-tradinggoose-test', + { 'MCP-Protocol-Version': '2025-06-18' } + ) + ) + const wrongProtocolHeaderResponse = await POST( + createMcpRequest( + { jsonrpc: '2.0', id: 12, method: 'tools/list' }, + 'Bearer sk-tradinggoose-test', + { 'MCP-Protocol-Version': '1.0' } + ) + ) + const invalidToolArgumentsResponse = await POST( + createMcpRequest({ + jsonrpc: '2.0', + id: 13, + method: 'tools/call', + params: { name: 'list_workflows', arguments: [] }, + }) + ) + const jsonRpcResponseMessage = await POST( + createMcpRequest({ jsonrpc: '2.0', id: 14, result: {} }) + ) + + expect((await invalidJsonRpcResponse.json()).error.code).toBe(-32600) + expect((await nullIdResponse.json()).error.code).toBe(-32600) + expect((await invalidInitializeResponse.json()).error.code).toBe(-32602) + const unsupportedVersionBody = await unsupportedVersionResponse.json() + expect(unsupportedVersionBody.error.code).toBe(-32000) + expect(unsupportedVersionBody.error.data.supportedProtocolVersions).toEqual([ + '2025-06-18', + '2025-03-26', + ]) + expect(notificationResponse.status).toBe(202) + expect(negotiatedProtocolHeaderResponse.status).toBe(200) + expect(wrongProtocolHeaderResponse.status).toBe(400) + expect((await wrongProtocolHeaderResponse.json()).error.message).toBe( + 'Unsupported MCP protocol version' + ) + const invalidToolArgumentsBody = await invalidToolArgumentsResponse.json() + expect(invalidToolArgumentsBody.error.code).toBe(-32602) + expect(invalidToolArgumentsBody.error.message).toBe('Invalid tools/call params') + expect(jsonRpcResponseMessage.status).toBe(202) + expect(await jsonRpcResponseMessage.text()).toBe('') + expect(mockRouteExecution).not.toHaveBeenCalled() + }) + + it('explicitly rejects GET streams because this MCP endpoint is POST-only', async () => { + const { GET } = await import('./route') + + const response = await GET() + + expect(response.status).toBe(405) + expect(response.headers.get('allow')).toBe('POST') + expect(response.headers.get('MCP-Protocol-Version')).toBe('2025-06-18') + }) + + it('returns per-entry invalid request errors for malformed batches', async () => { + const { POST } = await import('./route') + + const response = await POST(createMcpRequest([null])) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toEqual([ + { + jsonrpc: '2.0', + id: null, + error: { + code: -32600, + message: 'Invalid JSON-RPC request', + }, + }, + ]) + expect(mockRouteExecution).not.toHaveBeenCalled() + }) + + it('rejects empty JSON-RPC batches as invalid requests', async () => { + const { POST } = await import('./route') + + const response = await POST(createMcpRequest([])) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toEqual({ + jsonrpc: '2.0', + id: null, + error: { + code: -32600, + message: 'Invalid JSON-RPC request', + }, + }) + expect(mockRouteExecution).not.toHaveBeenCalled() + }) + + it('rejects oversized JSON-RPC batches before dispatch', async () => { + const { POST } = await import('./route') + + const response = await POST( + createMcpRequest( + Array.from({ length: 11 }, (_, index) => ({ + jsonrpc: '2.0', + id: index + 1, + method: 'tools/call', + params: { name: 'list_workflows', arguments: { workspaceId: 'workspace-1' } }, + })) + ) + ) + const body = await response.json() + + expect(body.error.message).toBe('JSON-RPC batch size cannot exceed 10') + expect(mockRouteExecution).not.toHaveBeenCalled() + }) + + it('rejects batched initialize requests', async () => { + const { POST } = await import('./route') + + const response = await POST(createMcpRequest([initializeRequest()])) + const body = await response.json() + + expect(body.error.code).toBe(-32600) + expect(body.error.message).toBe('initialize cannot be batched') + expect(mockGetUserWorkspaces).not.toHaveBeenCalled() + }) +}) diff --git a/apps/tradinggoose/app/api/copilot/mcp/route.ts b/apps/tradinggoose/app/api/copilot/mcp/route.ts new file mode 100644 index 000000000..f4886f020 --- /dev/null +++ b/apps/tradinggoose/app/api/copilot/mcp/route.ts @@ -0,0 +1,395 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { + checkApiEndpointRateLimit, + checkPublicApiEndpointRateLimit, + type RateLimitResult, +} from '@/lib/api/rate-limit' +import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service' +import { getCopilotRuntimeToolManifest } from '@/lib/copilot/runtime-tool-manifest' +import { buildCopilotServerToolErrorResponse } from '@/lib/copilot/server-tool-errors' +import { getMcpServerToolIds, routeExecution } from '@/lib/copilot/tools/server/router' +import { createDefaultWorkspaceForUser, getUserWorkspaces } from '@/lib/workspaces/service' + +export const dynamic = 'force-dynamic' + +const MCP_PROTOCOL_VERSION = '2025-06-18' +const MCP_DEFAULT_PROTOCOL_VERSION = '2025-03-26' +const MCP_NEGOTIABLE_PROTOCOL_VERSIONS = [MCP_PROTOCOL_VERSION, MCP_DEFAULT_PROTOCOL_VERSION] +const SERVER_NAME = 'TradingGoose' +const SERVER_VERSION = '0.1.0' +const MAX_JSON_RPC_BATCH_SIZE = 10 + +type JsonRpcId = string | number + +type JsonRpcRequest = { + jsonrpc?: unknown + id?: unknown + method?: unknown + params?: unknown +} + +type AuthenticatedMcpUser = { + userId: string +} + +function jsonRpcResult(id: JsonRpcId, result: unknown) { + return { + jsonrpc: '2.0', + id, + result, + } +} + +function jsonRpcError(id: JsonRpcId | null, code: number, message: string, data?: unknown) { + return { + jsonrpc: '2.0', + id, + error: { + code, + message, + ...(data === undefined ? {} : { data }), + }, + } +} + +function mcpJsonResponse( + body: unknown, + init?: ResponseInit, + protocolVersion = MCP_DEFAULT_PROTOCOL_VERSION +) { + const headers = new Headers(init?.headers) + const responseProtocolVersion = (body as { result?: { protocolVersion?: unknown } } | null) + ?.result?.protocolVersion + headers.set( + 'MCP-Protocol-Version', + typeof responseProtocolVersion === 'string' && + MCP_NEGOTIABLE_PROTOCOL_VERSIONS.includes(responseProtocolVersion) + ? responseProtocolVersion + : protocolVersion + ) + + return NextResponse.json(body, { + ...init, + headers, + }) +} + +function mcpAcceptedResponse(protocolVersion: string) { + return new NextResponse(null, { + status: 202, + headers: { 'MCP-Protocol-Version': protocolVersion }, + }) +} + +function mcpRateLimitResponse(result: RateLimitResult) { + const headers: Record = { + 'X-RateLimit-Limit': result.limit.toString(), + 'X-RateLimit-Remaining': result.remaining.toString(), + 'X-RateLimit-Reset': result.resetAt.toISOString(), + } + const retryAfter = Math.max(0, Math.ceil((result.resetAt.getTime() - Date.now()) / 1000)) + headers['Retry-After'] = retryAfter.toString() + + const status = + result.failureKind === 'auth' ? 401 : result.failureKind === 'dependency' ? 503 : 429 + const message = + result.failureKind === 'dependency' + ? result.error || 'Rate limit service unavailable' + : result.error || 'Rate limit exceeded' + + return mcpJsonResponse(jsonRpcError(null, -32029, message), { status, headers }) +} + +function getBearerToken(request: NextRequest) { + const authorization = request.headers.get('authorization') + const match = authorization?.match(/^Bearer\s+(.+)$/i) + if (!match) { + return null + } + + const token = match[1].trim() + return token || null +} + +async function authenticateCopilotMcpRequest( + request: NextRequest +): Promise { + const token = getBearerToken(request) + if (!token) { + return { error: 'Bearer token required' } + } + + const auth = await authenticateApiKeyFromHeader(token, { keyTypes: ['personal'] }) + if (!auth.success || !auth.userId) { + return { error: 'Invalid TradingGoose MCP token' } + } + + if (auth.keyId) { + await updateApiKeyLastUsed(auth.keyId) + } + + return { userId: auth.userId } +} + +async function buildInstructions(userId: string) { + const existingWorkspaces = await getUserWorkspaces({ userId }) + const workspaces = + existingWorkspaces.length > 0 + ? existingWorkspaces + : [await createDefaultWorkspaceForUser(userId)] + const workspaceLines = workspaces.map( + (workspace) => + `- ${workspace.name}: workspaceId=${workspace.id}, permissions=${workspace.permissions}` + ) + + return [ + 'TradingGoose Copilot MCP exposes server-side Copilot tools for trusted personal coding agents, including direct mutation tools.', + 'Local MCP config stores only this user auth token. Do not store workspaceId, entityId, or entity targets in the local MCP config.', + 'Use tools/list as the source of truth for each tool input schema; target identifiers are tool-specific and come from list/read tool results. Mutating tools execute directly for the authenticated MCP key; Studio review tokens are not part of the external MCP protocol. Credential, OAuth, and environment reads require scope="personal" for the authenticated user or scope="workspace" with workspaceId. Workspace-scoped tools, including list/create, Google Drive, and workspace account reads, require workspaceId. Environment writes use the same personal/workspace scope rule.', + 'MCP server documents redact header/env secret values as [redacted]. Keep [redacted] to preserve an existing secret, send a concrete value to replace it, or omit the key to delete it.', + 'Accessible workspaces for the authenticated user:', + ...workspaceLines, + ].join('\n') +} + +async function listMcpTools() { + const serverToolIds = new Set(getMcpServerToolIds()) + const manifest = await getCopilotRuntimeToolManifest() + + return manifest.tools + .filter((tool) => serverToolIds.has(tool.name)) + .map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.parameters ?? { + type: 'object', + properties: {}, + additionalProperties: true, + }, + })) +} + +function getToolCallParams(params: unknown) { + if (!params || typeof params !== 'object' || Array.isArray(params)) { + return null + } + + const { name, arguments: args } = params as { name?: unknown; arguments?: unknown } + if (typeof name !== 'string' || name.trim().length === 0) { + return null + } + if (args !== undefined && (!args || typeof args !== 'object' || Array.isArray(args))) { + return null + } + + return { + name, + args: args ?? {}, + } +} + +function isJsonRpcRequest(value: unknown): value is JsonRpcRequest { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function isJsonRpcResponse(value: unknown) { + if (!isJsonRpcRequest(value) || value.jsonrpc !== '2.0' || value.method !== undefined) { + return false + } + return 'result' in value || 'error' in value +} + +function getResponseId(request: JsonRpcRequest): JsonRpcId | null { + return typeof request.id === 'string' || typeof request.id === 'number' ? request.id : null +} + +function getInitializeProtocolVersion(params: unknown) { + if (!params || typeof params !== 'object' || Array.isArray(params)) { + return null + } + + const protocolVersion = (params as { protocolVersion?: unknown }).protocolVersion + return typeof protocolVersion === 'string' ? protocolVersion : null +} + +function isInitializeRequest(value: unknown) { + return isJsonRpcRequest(value) && value.method === 'initialize' +} + +async function handleJsonRpcRequest(entry: unknown, auth: AuthenticatedMcpUser) { + if (!isJsonRpcRequest(entry)) { + return jsonRpcError(null, -32600, 'Invalid JSON-RPC request') + } + + const request = entry + const id = getResponseId(request) + if (request.jsonrpc !== '2.0') { + return jsonRpcError(id, -32600, 'Invalid JSON-RPC request') + } + if (typeof request.method !== 'string') { + return jsonRpcError(id, -32600, 'Invalid JSON-RPC request') + } + + if (request.id === undefined) { + return null + } + if (id === null) { + return jsonRpcError(null, -32600, 'Invalid JSON-RPC request') + } + + try { + switch (request.method) { + case 'initialize': { + const protocolVersion = getInitializeProtocolVersion(request.params) + if (!protocolVersion) { + return jsonRpcError(id, -32602, 'Invalid initialize params') + } + if (!MCP_NEGOTIABLE_PROTOCOL_VERSIONS.includes(protocolVersion)) { + return jsonRpcError(id, -32000, 'Unsupported MCP protocol version', { + supportedProtocolVersions: MCP_NEGOTIABLE_PROTOCOL_VERSIONS, + }) + } + + return jsonRpcResult(id, { + protocolVersion, + capabilities: { + tools: {}, + }, + serverInfo: { + name: SERVER_NAME, + version: SERVER_VERSION, + }, + instructions: await buildInstructions(auth.userId), + }) + } + + case 'ping': + return jsonRpcResult(id, {}) + + case 'tools/list': + return jsonRpcResult(id, { + tools: await listMcpTools(), + }) + + case 'tools/call': { + const toolCall = getToolCallParams(request.params) + if (!toolCall) { + return jsonRpcError(id, -32602, 'Invalid tools/call params') + } + if (!getMcpServerToolIds().some((toolName) => toolName === toolCall.name)) { + return jsonRpcError(id, -32601, `Unsupported MCP tool: ${toolCall.name}`) + } + + // Tool-execution failures are MCP tool results (isError), not protocol errors, + // so the agent sees them; both paths shape errors via the shared sanitizer. + try { + const result = await routeExecution(toolCall.name, toolCall.args, { + userId: auth.userId, + accessLevel: 'full', + }) + return jsonRpcResult(id, { + content: [{ type: 'text', text: JSON.stringify(result, null, 2) }], + structuredContent: result, + }) + } catch (error) { + const structuredError = buildCopilotServerToolErrorResponse(toolCall.name, error) + return jsonRpcResult(id, { + isError: true, + content: [{ type: 'text', text: JSON.stringify(structuredError.body, null, 2) }], + structuredContent: structuredError.body, + }) + } + } + + case 'resources/list': + return jsonRpcResult(id, { resources: [] }) + + case 'prompts/list': + return jsonRpcResult(id, { prompts: [] }) + + default: + return jsonRpcError(id, -32601, `Unsupported MCP method: ${request.method}`) + } + } catch (error) { + // Any other method (initialize/tools/list/...) that throws is sanitized through + // the same path as Studio instead of leaking a raw Next.js error response. + const structuredError = buildCopilotServerToolErrorResponse(undefined, error) + return jsonRpcError(id, -32603, structuredError.body.error, structuredError.body) + } +} + +export async function POST(request: NextRequest) { + const publicRateLimit = await checkPublicApiEndpointRateLimit(request, 'copilot-mcp-public') + if (!publicRateLimit.allowed) { + return mcpRateLimitResponse(publicRateLimit) + } + + const auth = await authenticateCopilotMcpRequest(request) + if ('error' in auth) { + return mcpJsonResponse(jsonRpcError(null, -32001, auth.error), { status: 401 }) + } + + const rateLimit = await checkApiEndpointRateLimit(auth.userId, 'copilot-mcp') + if (!rateLimit.allowed) { + return mcpRateLimitResponse(rateLimit) + } + + const body = (await request.json().catch(() => null)) as JsonRpcRequest | JsonRpcRequest[] | null + if (!body) { + return mcpJsonResponse(jsonRpcError(null, -32700, 'Invalid JSON body'), { status: 400 }) + } + + const requestProtocolVersion = request.headers.get('MCP-Protocol-Version') + const isInitialize = Array.isArray(body) + ? body.some(isInitializeRequest) + : isInitializeRequest(body) + const protocolVersion = + requestProtocolVersion ?? (isInitialize ? MCP_PROTOCOL_VERSION : MCP_DEFAULT_PROTOCOL_VERSION) + const json = (body: unknown, init?: ResponseInit) => mcpJsonResponse(body, init, protocolVersion) + const accepted = () => mcpAcceptedResponse(protocolVersion) + + if (!isInitialize && !MCP_NEGOTIABLE_PROTOCOL_VERSIONS.includes(protocolVersion)) { + return json(jsonRpcError(null, -32000, 'Unsupported MCP protocol version'), { + status: 400, + }) + } + + if (Array.isArray(body)) { + if (body.length === 0) { + return json(jsonRpcError(null, -32600, 'Invalid JSON-RPC request')) + } + if (body.length > MAX_JSON_RPC_BATCH_SIZE) { + return json( + jsonRpcError(null, -32600, `JSON-RPC batch size cannot exceed ${MAX_JSON_RPC_BATCH_SIZE}`) + ) + } + if (body.some(isInitializeRequest)) { + return json(jsonRpcError(null, -32600, 'initialize cannot be batched')) + } + + const responses = [] + for (const entry of body) { + if (isJsonRpcResponse(entry)) continue + const response = await handleJsonRpcRequest(entry, auth) + if (response) responses.push(response) + } + + return responses.length > 0 ? json(responses) : accepted() + } + + if (isJsonRpcResponse(body)) { + return accepted() + } + const response = await handleJsonRpcRequest(body, auth) + return response ? json(response) : accepted() +} + +export async function GET() { + return new NextResponse(null, { + status: 405, + headers: { + Allow: 'POST', + 'MCP-Protocol-Version': MCP_PROTOCOL_VERSION, + }, + }) +} diff --git a/apps/tradinggoose/app/api/copilot/tools/mark-complete/route.test.ts b/apps/tradinggoose/app/api/copilot/tools/mark-complete/route.test.ts index dbe08d223..43e951292 100644 --- a/apps/tradinggoose/app/api/copilot/tools/mark-complete/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/tools/mark-complete/route.test.ts @@ -17,10 +17,11 @@ function createSseStream(events: unknown[]): ReadableStream { } describe('Copilot mark-complete API', () => { + let POST: typeof import('./route').POST const mockAuthenticateCopilotRequestSessionOnly = vi.fn() const mockProxyCopilotRequest = vi.fn() - beforeEach(() => { + beforeEach(async () => { vi.resetModules() mockAuthenticateCopilotRequestSessionOnly.mockReset() mockProxyCopilotRequest.mockReset() @@ -56,10 +57,28 @@ describe('Copilot mark-complete API', () => { })), })) + vi.doMock('@/lib/copilot/completion-usage-billing', () => ({ + mirrorLocalCopilotCompletionUsageReports: vi.fn().mockResolvedValue(undefined), + })) + + vi.doMock('@/lib/utils', () => ({ + encodeSSE: vi.fn((event: unknown) => + new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`) + ), + SSE_HEADERS: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + })) + vi.doMock('@/app/api/copilot/proxy', () => ({ getCopilotApiUrl: vi.fn(() => 'https://copilot.example.test/api/tools/mark-complete'), proxyCopilotRequest: (...args: any[]) => mockProxyCopilotRequest(...args), })) + + ;({ POST } = await import('./route')) }) it('passes through a continuation SSE stream from copilot', async () => { @@ -98,8 +117,6 @@ describe('Copilot mark-complete API', () => { ) ) - const { POST } = await import('./route') - const response = await POST( new NextRequest('http://localhost:3000/api/copilot/tools/mark-complete', { method: 'POST', diff --git a/apps/tradinggoose/app/api/copilot/tools/mark-complete/route.ts b/apps/tradinggoose/app/api/copilot/tools/mark-complete/route.ts index cb7a7741a..6d6271f68 100644 --- a/apps/tradinggoose/app/api/copilot/tools/mark-complete/route.ts +++ b/apps/tradinggoose/app/api/copilot/tools/mark-complete/route.ts @@ -151,27 +151,8 @@ export async function POST(req: NextRequest) { } const body = await req.json() - - // Log raw body shape for diagnostics (avoid dumping huge payloads) - try { - const bodyPreview = JSON.stringify(body).slice(0, 300) - logger.debug(`[${tracker.requestId}] Incoming mark-complete raw body preview`, { - preview: `${bodyPreview}${bodyPreview.length === 300 ? '...' : ''}`, - }) - } catch {} - const parsed = MarkCompleteSchema.parse(body) - const messagePreview = (() => { - try { - const s = - typeof parsed.message === 'string' ? parsed.message : JSON.stringify(parsed.message) - return s ? `${s.slice(0, 200)}${s.length > 200 ? '...' : ''}` : undefined - } catch { - return undefined - } - })() - logger.info(`[${tracker.requestId}] Forwarding tool mark-complete`, { userId, toolCallId: parsed.id, @@ -179,7 +160,6 @@ export async function POST(req: NextRequest) { status: parsed.status, hasMessage: parsed.message !== undefined, hasData: parsed.data !== undefined, - messagePreview, agentUrl: await getCopilotApiUrl('/api/tools/mark-complete'), }) diff --git a/apps/tradinggoose/app/api/function/execute/route.test.ts b/apps/tradinggoose/app/api/function/execute/route.test.ts index f297e7284..355fc1db3 100644 --- a/apps/tradinggoose/app/api/function/execute/route.test.ts +++ b/apps/tradinggoose/app/api/function/execute/route.test.ts @@ -6,11 +6,7 @@ import { createMockRequest } from '@/app/api/__test-utils__/utils' const checkInternalAuthMock = vi.fn() const checkWorkspaceAccessMock = vi.fn() -const checkServerSideUsageLimitsMock = vi.fn() -const executeFunctionWithRuntimeGateMock = vi.fn() -const listCustomIndicatorRuntimeEntriesMock = vi.fn() -const isBillingEnabledForRuntimeMock = vi.fn() -const accrueUserUsageCostMock = vi.fn() +const executeFunctionRequestMock = vi.fn() const readWorkflowByIdMock = vi.fn() const loggerMock = { info: vi.fn(), @@ -37,25 +33,18 @@ describe('Function Execute API Route', () => { success: true, userId: 'user-1', }) - checkServerSideUsageLimitsMock.mockResolvedValue({ - isExceeded: false, - currentUsage: 0, - limit: 100, - }) checkWorkspaceAccessMock.mockResolvedValue({ hasAccess: true, canWrite: true }) - executeFunctionWithRuntimeGateMock.mockResolvedValue({ - engine: 'local_vm', - success: true, - result: 'ok', - stdout: 'stdout', - executionTime: 2400, - userCodeStartLine: 3, + executeFunctionRequestMock.mockResolvedValue({ + statusCode: 200, + body: { + success: true, + output: { + result: 'ok', + stdout: 'stdout', + executionTime: 2400, + }, + }, }) - listCustomIndicatorRuntimeEntriesMock.mockResolvedValue([ - { id: 'indicator-1', pineCode: 'indicator("Custom Indicator")' }, - ]) - isBillingEnabledForRuntimeMock.mockResolvedValue(false) - accrueUserUsageCostMock.mockResolvedValue(true) vi.doMock('@/lib/auth/hybrid', () => ({ checkInternalAuth: checkInternalAuthMock, @@ -66,74 +55,18 @@ describe('Function Execute API Route', () => { vi.doMock('@/lib/utils', () => ({ generateRequestId: vi.fn(() => 'request-1'), })) - vi.doMock('@/lib/billing', () => ({ - checkServerSideUsageLimits: checkServerSideUsageLimitsMock, - })) - vi.doMock('@/lib/billing/settings', () => ({ - getResolvedBillingSettings: vi.fn().mockResolvedValue({ - functionExecutionChargeUsd: 0.25, - }), - isBillingEnabledForRuntime: isBillingEnabledForRuntimeMock, - })) - vi.doMock('@/lib/billing/tiers', () => ({ - getTierFunctionExecutionMultiplier: vi.fn(() => 0.5), - })) - vi.doMock('@/lib/billing/workspace-billing', () => ({ - resolveWorkflowBillingContext: vi.fn().mockResolvedValue({ - tier: { id: 'tier-1' }, - }), - resolveWorkspaceBillingContext: vi.fn().mockResolvedValue({ - tier: { id: 'tier-1' }, - }), - })) - vi.doMock('@/lib/billing/usage-accrual', () => ({ - accrueUserUsageCost: accrueUserUsageCostMock, - })) - vi.doMock('@/app/api/function/code-resolution', () => ({ - resolveCodeVariables: vi.fn((code: string) => ({ - resolvedCode: code, - contextVariables: {}, - })), - })) - vi.doMock('@/app/api/function/typescript-utils', () => ({ - findFunctionPineDisallowedReason: vi.fn(async () => null), - transpileTypeScriptCode: vi.fn(async (code: string) => code), - })) - vi.doMock('@/app/api/function/error-formatting', () => ({ - createUserFriendlyErrorMessage: vi.fn( - (error: { message?: string }) => error.message ?? 'Function execution failed' - ), - extractEnhancedError: vi.fn((error: Error) => ({ - message: error.message, - name: error.name, - stack: error.stack, - })), - })) - vi.doMock('@/app/api/function/e2b-execution', () => ({ - executeFunctionWithRuntimeGate: executeFunctionWithRuntimeGateMock, - })) - vi.doMock('@/lib/indicators/custom/operations', () => ({ - listCustomIndicatorRuntimeEntries: listCustomIndicatorRuntimeEntriesMock, - })) vi.doMock('@/lib/permissions/utils', () => ({ checkWorkspaceAccess: checkWorkspaceAccessMock, })) + vi.doMock('@/lib/function/execution', () => ({ + executeFunctionRequest: executeFunctionRequestMock, + })) vi.doMock('@/lib/workflows/utils', () => ({ readWorkflowById: readWorkflowByIdMock.mockResolvedValue({ id: 'workflow-1', workspaceId: 'workspace-1', }), })) - vi.doMock('@/lib/execution/local-saturation-limit', () => ({ - getLocalVmSaturationLimitMessage: vi.fn(() => 'Local VM saturated'), - isLocalVmSaturationLimitError: vi.fn((error: unknown) => - Boolean( - error && - typeof error === 'object' && - (error as { code?: string }).code === 'LOCAL_VM_SATURATION_LIMIT' - ) - ), - })) }) it('rejects requests without internal auth', async () => { @@ -146,6 +79,7 @@ describe('Function Execute API Route', () => { expect(response.status).toBe(401) expect(payload.success).toBe(false) expect(payload.error).toBe('Unauthorized') + expect(executeFunctionRequestMock).not.toHaveBeenCalled() }) it('accepts exactly one execution scope', async () => { @@ -159,6 +93,16 @@ describe('Function Execute API Route', () => { expect(workspaceResponse.status).toBe(200) expect(readWorkflowByIdMock).not.toHaveBeenCalled() + expect(executeFunctionRequestMock).toHaveBeenCalledOnce() + expect(executeFunctionRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'return "ok"', + workflowId: undefined, + workspaceId: 'workspace-1', + userId: 'user-1', + requestId: 'request-1', + }) + ) const mixedScopeResponse = await POST( createMockRequest('POST', { @@ -173,7 +117,7 @@ describe('Function Execute API Route', () => { expect(mixedScopePayload.error).toBe( 'Function execution accepts either workflow or workspace context, not both' ) - expect(executeFunctionWithRuntimeGateMock).toHaveBeenCalledOnce() + expect(executeFunctionRequestMock).toHaveBeenCalledOnce() }) it('executes under workflow context', async () => { @@ -184,21 +128,17 @@ describe('Function Execute API Route', () => { expect(response.status).toBe(200) expect(payload.success).toBe(true) expect(payload.output.result).toBe('ok') - expect(checkServerSideUsageLimitsMock).toHaveBeenCalledWith({ - userId: 'user-1', - workspaceId: 'workspace-1', - workflowId: 'workflow-1', - }) expect(checkWorkspaceAccessMock).toHaveBeenCalledWith('workspace-1', 'user-1') - expect(listCustomIndicatorRuntimeEntriesMock).toHaveBeenCalledWith('workspace-1') - expect(executeFunctionWithRuntimeGateMock).toHaveBeenCalledWith( + expect(executeFunctionRequestMock).toHaveBeenCalledWith( expect.objectContaining({ - indicatorRuntimeManifest: expect.objectContaining({ - indicators: expect.arrayContaining([expect.objectContaining({ id: 'indicator-1' })]), - }), + code: 'return "ok"', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + userId: 'user-1', + requestId: 'request-1', }) ) - expect(executeFunctionWithRuntimeGateMock).toHaveBeenCalledOnce() + expect(executeFunctionRequestMock).toHaveBeenCalledOnce() }) it('rejects workflow requests when workspace access is denied', async () => { @@ -211,7 +151,7 @@ describe('Function Execute API Route', () => { expect(response.status).toBe(403) expect(payload.success).toBe(false) expect(payload.error).toBe('Access denied') - expect(executeFunctionWithRuntimeGateMock).not.toHaveBeenCalled() + expect(executeFunctionRequestMock).not.toHaveBeenCalled() }) it('rejects workspace-scoped function execution for read-only workspace members', async () => { @@ -229,15 +169,21 @@ describe('Function Execute API Route', () => { expect(response.status).toBe(403) expect(payload.success).toBe(false) expect(payload.error).toBe('Access denied') - expect(executeFunctionWithRuntimeGateMock).not.toHaveBeenCalled() + expect(executeFunctionRequestMock).not.toHaveBeenCalled() }) - it('blocks before runtime when workflow usage is exceeded', async () => { - checkServerSideUsageLimitsMock.mockResolvedValueOnce({ - isExceeded: true, - currentUsage: 101, - limit: 100, - message: 'Usage limit exceeded', + it('forwards execution service failures', async () => { + executeFunctionRequestMock.mockResolvedValueOnce({ + statusCode: 402, + body: { + success: false, + error: 'Usage limit exceeded', + output: { + result: null, + stdout: '', + executionTime: 10, + }, + }, }) const { POST } = await import('@/app/api/function/execute/route') @@ -247,47 +193,21 @@ describe('Function Execute API Route', () => { expect(response.status).toBe(402) expect(payload.success).toBe(false) expect(payload.error).toBe('Usage limit exceeded') - expect(executeFunctionWithRuntimeGateMock).not.toHaveBeenCalled() - }) - - it('accrues workflow-scoped function execution cost after runtime finishes', async () => { - isBillingEnabledForRuntimeMock.mockResolvedValueOnce(true) - - const { POST } = await import('@/app/api/function/execute/route') - const response = await POST(createFunctionRequest()) - - expect(response.status).toBe(200) - expect(accrueUserUsageCostMock).toHaveBeenCalledWith({ - userId: 'user-1', - workspaceId: 'workspace-1', - workflowId: 'workflow-1', - cost: 0.3, - reason: 'function_execution', - }) - }) - - it('keeps runtime success when post-run billing accrual fails', async () => { - isBillingEnabledForRuntimeMock.mockResolvedValueOnce(true) - accrueUserUsageCostMock.mockRejectedValueOnce(new Error('billing unavailable')) - - const { POST } = await import('@/app/api/function/execute/route') - const response = await POST(createFunctionRequest()) - const payload = await response.json() - - expect(response.status).toBe(200) - expect(payload.success).toBe(true) - expect(payload.output.result).toBe('ok') + expect(executeFunctionRequestMock).toHaveBeenCalledOnce() }) - it('returns runtime failures without retrying through pending execution', async () => { - executeFunctionWithRuntimeGateMock.mockResolvedValueOnce({ - engine: 'local_vm', - success: false, - result: null, - stdout: 'failure stdout', - executionTime: 500, - error: 'Boom', - userCodeStartLine: 3, + it('returns runtime failures from the execution service', async () => { + executeFunctionRequestMock.mockResolvedValueOnce({ + statusCode: 500, + body: { + success: false, + output: { + result: null, + stdout: 'failure stdout', + executionTime: 500, + }, + error: 'Boom', + }, }) const { POST } = await import('@/app/api/function/execute/route') diff --git a/apps/tradinggoose/app/api/indicators/custom/import/route.test.ts b/apps/tradinggoose/app/api/indicators/custom/import/route.test.ts index fdfe1d765..b80cfb58e 100644 --- a/apps/tradinggoose/app/api/indicators/custom/import/route.test.ts +++ b/apps/tradinggoose/app/api/indicators/custom/import/route.test.ts @@ -73,7 +73,9 @@ describe('Indicators import route', () => { { name: 'RSI Export Example', pineCode: "indicator('RSI Export Example')", - inputMeta: {}, + inputMeta: { + Stale: { title: 'Stale', type: 'string', defval: 'old' }, + }, }, ], }, @@ -93,7 +95,6 @@ describe('Indicators import route', () => { { name: 'RSI Export Example', pineCode: "indicator('RSI Export Example')", - inputMeta: {}, }, ], }) diff --git a/apps/tradinggoose/app/api/indicators/custom/import/route.ts b/apps/tradinggoose/app/api/indicators/custom/import/route.ts index e7aac5fed..e6d542828 100644 --- a/apps/tradinggoose/app/api/indicators/custom/import/route.ts +++ b/apps/tradinggoose/app/api/indicators/custom/import/route.ts @@ -4,6 +4,7 @@ import { importIndicators } from '@/lib/indicators/custom/operations' import { parseImportedIndicatorsFile } from '@/lib/indicators/import-export' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' import { authenticateIndicatorRequest, checkWorkspacePermission } from '@/app/api/indicators/utils' const logger = createLogger('IndicatorsImportAPI') @@ -79,6 +80,9 @@ export async function POST(request: NextRequest) { throw validationError } } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } logger.error(`[${requestId}] Error importing indicators`, { error }) return NextResponse.json({ error: 'Failed to import indicators' }, { status: 500 }) } diff --git a/apps/tradinggoose/app/api/indicators/custom/route.ts b/apps/tradinggoose/app/api/indicators/custom/route.ts index 43d4acd7d..d430b09c7 100644 --- a/apps/tradinggoose/app/api/indicators/custom/route.ts +++ b/apps/tradinggoose/app/api/indicators/custom/route.ts @@ -1,13 +1,17 @@ import { db } from '@tradinggoose/db' -import { pineIndicators, workflow } from '@tradinggoose/db/schema' -import { and, desc, eq } from 'drizzle-orm' +import { pineIndicators } from '@tradinggoose/db/schema' +import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { upsertIndicators } from '@/lib/indicators/custom/operations' +import { createIndicators, listIndicators, saveIndicator } from '@/lib/indicators/custom/operations' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { applySavedEntityYjsStateToRows } from '@/lib/yjs/entity-state' -import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' +import { SavedEntityPersistenceError } from '@/lib/yjs/server/apply-entity-state' +import { + deleteYjsSessionInSocketServer, + notifyEntityListMemberRemoved, +} from '@/lib/yjs/server/snapshot-bridge' import { authenticateIndicatorRequest, checkWorkspacePermission } from '../utils' const logger = createLogger('IndicatorsAPI') @@ -27,7 +31,9 @@ const logWorkspacePermissionDenied = ({ logger.warn(`[${requestId}] User ${userId} does not have access to workspace ${workspaceId}`) return } - logger.warn(`[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}`) + logger.warn( + `[${requestId}] User ${userId} does not have write permission for workspace ${workspaceId}` + ) } const IndicatorSchema = z.object({ @@ -37,7 +43,6 @@ const IndicatorSchema = z.object({ id: z.string().optional(), name: z.string().min(1, 'Indicator name is required'), pineCode: z.string().default(''), - inputMeta: z.record(z.any()).optional(), }) ), }) @@ -46,7 +51,6 @@ export async function GET(request: NextRequest) { const requestId = generateRequestId() const searchParams = request.nextUrl.searchParams const workspaceId = searchParams.get('workspaceId') - const workflowId = searchParams.get('workflowId') try { const auth = await authenticateIndicatorRequest({ @@ -59,54 +63,31 @@ export async function GET(request: NextRequest) { if ('response' in auth) return auth.response const userId = auth.userId - let resolvedWorkspaceId: string | null = workspaceId - - if (!resolvedWorkspaceId && workflowId) { - const [workflowData] = await db - .select({ workspaceId: workflow.workspaceId }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (!workflowData?.workspaceId) { - logger.warn(`[${requestId}] Workflow not found: ${workflowId}`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - resolvedWorkspaceId = workflowData.workspaceId - } - - if (!resolvedWorkspaceId) { + if (!workspaceId) { logger.warn(`[${requestId}] Missing workspaceId for indicators fetch`) return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) } - if (!(auth.authType === 'internal_jwt' && workflowId)) { - const permissionCheck = await checkWorkspacePermission({ + const permissionCheck = await checkWorkspacePermission({ + userId, + workspaceId, + responseShape: 'errorOnly', + }) + if (!permissionCheck.ok) { + logWorkspacePermissionDenied({ + requestId, userId, - workspaceId: resolvedWorkspaceId, - responseShape: 'errorOnly', + workspaceId, + code: permissionCheck.code, }) - if (!permissionCheck.ok) { - logWorkspacePermissionDenied({ - requestId, - userId, - workspaceId: resolvedWorkspaceId, - code: permissionCheck.code, - }) - return permissionCheck.response - } + return permissionCheck.response } - const rows = await db - .select() - .from(pineIndicators) - .where(eq(pineIndicators.workspaceId, resolvedWorkspaceId)) - .orderBy(desc(pineIndicators.createdAt)) - const result = await applySavedEntityYjsStateToRows('indicator', rows) - - return NextResponse.json({ data: result }, { status: 200 }) + return NextResponse.json({ data: await listIndicators({ workspaceId }) }, { status: 200 }) } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } logger.error(`[${requestId}] Error fetching indicators:`, error) return NextResponse.json({ error: 'Failed to fetch indicators' }, { status: 500 }) } @@ -146,12 +127,38 @@ export async function POST(request: NextRequest) { return permissionCheck.response } - const resultIndicators = await upsertIndicators({ - indicators, - workspaceId, - userId: auth.userId, - requestId, - }) + const indicatorsToCreate = indicators.filter((indicator) => !indicator.id) + const indicatorsToSave = indicators.filter((indicator) => indicator.id) + if (indicatorsToCreate.length > 0 && indicatorsToSave.length > 0) { + return NextResponse.json( + { error: 'Create and save indicators in separate requests' }, + { status: 400 } + ) + } + if (indicatorsToSave.length > 1) { + return NextResponse.json( + { error: 'Save one existing indicator per request' }, + { status: 400 } + ) + } + + const resultIndicators = + indicatorsToSave.length === 1 + ? await saveIndicator({ + indicator: { + id: indicatorsToSave[0].id!, + name: indicatorsToSave[0].name, + pineCode: indicatorsToSave[0].pineCode, + }, + workspaceId, + requestId, + }) + : await createIndicators({ + indicators: indicatorsToCreate, + workspaceId, + userId: auth.userId, + requestId, + }) return NextResponse.json({ success: true, data: resultIndicators }) } catch (validationError) { @@ -172,9 +179,18 @@ export async function POST(request: NextRequest) { { status: 400 } ) } + if (validationError instanceof SavedEntityPersistenceError) { + return NextResponse.json(validationError.responseBody(), { status: validationError.status }) + } + if (validationError instanceof Error && validationError.message.includes('was not found')) { + return NextResponse.json({ error: validationError.message }, { status: 404 }) + } throw validationError } } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } logger.error(`[${requestId}] Error updating indicators`, error) return NextResponse.json({ error: 'Failed to update indicators' }, { status: 500 }) } @@ -232,11 +248,15 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Indicator not found' }, { status: 404 }) } - await deleteYjsSessionInSocketServer(indicatorId) await db .delete(pineIndicators) .where(and(eq(pineIndicators.id, indicatorId), eq(pineIndicators.workspaceId, workspaceId))) + await Promise.allSettled([ + deleteYjsSessionInSocketServer(indicatorId), + notifyEntityListMemberRemoved('indicator', workspaceId, indicatorId), + ]) + logger.info(`[${requestId}] Deleted indicator ${indicatorId}`) return NextResponse.json({ success: true }, { status: 200 }) } catch (error) { diff --git a/apps/tradinggoose/app/api/indicators/options/route.test.ts b/apps/tradinggoose/app/api/indicators/options/route.test.ts index 228837d7d..cfb08e307 100644 --- a/apps/tradinggoose/app/api/indicators/options/route.test.ts +++ b/apps/tradinggoose/app/api/indicators/options/route.test.ts @@ -8,38 +8,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockAuthenticateIndicatorRequest, mockCheckWorkspacePermission, - mockFrom, - mockSelect, - mockWhere, + mockListIndicators, mockIsIndicatorTriggerCapable, } = vi.hoisted(() => ({ mockAuthenticateIndicatorRequest: vi.fn(), mockCheckWorkspacePermission: vi.fn(), - mockFrom: vi.fn(), - mockSelect: vi.fn(), - mockWhere: vi.fn(), + mockListIndicators: vi.fn(), mockIsIndicatorTriggerCapable: vi.fn(), })) -vi.mock('@tradinggoose/db', () => ({ - db: { - select: mockSelect, - }, -})) - -vi.mock('@tradinggoose/db/schema', () => ({ - pineIndicators: { - id: 'pineIndicators.id', - name: 'pineIndicators.name', - color: 'pineIndicators.color', - pineCode: 'pineIndicators.pineCode', - inputMeta: 'pineIndicators.inputMeta', - workspaceId: 'pineIndicators.workspaceId', - }, -})) - -vi.mock('drizzle-orm', () => ({ - eq: vi.fn((field: unknown, value: unknown) => ({ field, type: 'eq', value })), +vi.mock('@/lib/indicators/custom/operations', () => ({ + listIndicators: (...args: unknown[]) => mockListIndicators(...args), })) vi.mock('@/lib/indicators/default/runtime', () => ({ @@ -81,7 +60,7 @@ describe('indicator options route', () => { }) mockCheckWorkspacePermission.mockResolvedValue({ ok: true, permission: 'admin' }) mockIsIndicatorTriggerCapable.mockImplementation((code: string) => code === 'trigger-capable') - mockWhere.mockResolvedValue([ + mockListIndicators.mockResolvedValue([ { id: 'custom-trigger', name: 'Custom Trigger', @@ -111,8 +90,6 @@ describe('indicator options route', () => { }, }, ]) - mockFrom.mockReturnValue({ where: mockWhere }) - mockSelect.mockReturnValue({ from: mockFrom }) }) const getOptions = async (search: string) => { diff --git a/apps/tradinggoose/app/api/indicators/options/route.ts b/apps/tradinggoose/app/api/indicators/options/route.ts index abbb380ce..e1a40d3ed 100644 --- a/apps/tradinggoose/app/api/indicators/options/route.ts +++ b/apps/tradinggoose/app/api/indicators/options/route.ts @@ -1,15 +1,13 @@ -import { db } from '@tradinggoose/db' -import { pineIndicators } from '@tradinggoose/db/schema' -import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { listIndicators } from '@/lib/indicators/custom/operations' import { DEFAULT_INDICATOR_RUNTIME_ENTRIES } from '@/lib/indicators/default/runtime' import { normalizeInputMetaMap } from '@/lib/indicators/input-meta' -import type { InputMetaMap } from '@/lib/indicators/types' import { isIndicatorTriggerCapable } from '@/lib/indicators/trigger-detection' +import type { InputMetaMap } from '@/lib/indicators/types' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { applySavedEntityYjsStateToRows } from '@/lib/yjs/entity-state' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' import { authenticateIndicatorRequest, checkWorkspacePermission } from '../utils' const logger = createLogger('IndicatorOptionsAPI') @@ -86,18 +84,7 @@ export async function GET(request: NextRequest) { } }) - const customRows = await db - .select({ - id: pineIndicators.id, - workspaceId: pineIndicators.workspaceId, - name: pineIndicators.name, - color: pineIndicators.color, - pineCode: pineIndicators.pineCode, - inputMeta: pineIndicators.inputMeta, - }) - .from(pineIndicators) - .where(eq(pineIndicators.workspaceId, workspaceId)) - .then((rows) => applySavedEntityYjsStateToRows('indicator', rows)) + const customRows = await listIndicators({ workspaceId }) const customOptions: IndicatorOptionRecord[] = customRows .filter((row) => copilotSurface || isIndicatorTriggerCapable(row.pineCode)) @@ -125,6 +112,9 @@ export async function GET(request: NextRequest) { return NextResponse.json({ data: merged }, { status: 200 }) } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } logger.error(`[${requestId}] Failed to list indicator options`, { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } diff --git a/apps/tradinggoose/app/api/knowledge/[id]/copy/route.ts b/apps/tradinggoose/app/api/knowledge/[id]/copy/route.ts index 030213c9a..32d6b4e1b 100644 --- a/apps/tradinggoose/app/api/knowledge/[id]/copy/route.ts +++ b/apps/tradinggoose/app/api/knowledge/[id]/copy/route.ts @@ -4,6 +4,7 @@ import { getSession } from '@/lib/auth' import { copyKnowledgeBaseToWorkspace } from '@/lib/knowledge/service' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' const logger = createLogger('KnowledgeBaseCopyAPI') @@ -44,6 +45,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: data: copiedKnowledgeBase, }) } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } if (error instanceof z.ZodError) { return NextResponse.json( { error: 'Invalid request data', details: error.errors }, diff --git a/apps/tradinggoose/app/api/knowledge/[id]/route.test.ts b/apps/tradinggoose/app/api/knowledge/[id]/route.test.ts index 2c70f5d90..d39053991 100644 --- a/apps/tradinggoose/app/api/knowledge/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/knowledge/[id]/route.test.ts @@ -18,7 +18,7 @@ mockConsoleLogger() vi.mock('@/lib/knowledge/service', () => ({ getKnowledgeBaseById: vi.fn(), - updateKnowledgeBase: vi.fn(), + applyKnowledgeBaseMetadata: vi.fn(), deleteKnowledgeBase: vi.fn(), })) @@ -31,7 +31,7 @@ describe('Knowledge Base By ID API Route', () => { const mockAuth$ = mockAuth() let mockGetKnowledgeBaseById: any - let mockUpdateKnowledgeBase: any + let mockApplyKnowledgeBaseMetadata: any let mockDeleteKnowledgeBase: any let mockCheckKnowledgeBaseAccess: any let mockCheckKnowledgeBaseWriteAccess: any @@ -84,7 +84,7 @@ describe('Knowledge Base By ID API Route', () => { const knowledgeUtils = await import('@/app/api/knowledge/utils') mockGetKnowledgeBaseById = knowledgeService.getKnowledgeBaseById as any - mockUpdateKnowledgeBase = knowledgeService.updateKnowledgeBase as any + mockApplyKnowledgeBaseMetadata = knowledgeService.applyKnowledgeBaseMetadata as any mockDeleteKnowledgeBase = knowledgeService.deleteKnowledgeBase as any mockCheckKnowledgeBaseAccess = knowledgeUtils.checkKnowledgeBaseAccess as any mockCheckKnowledgeBaseWriteAccess = knowledgeUtils.checkKnowledgeBaseWriteAccess as any @@ -218,7 +218,8 @@ describe('Knowledge Base By ID API Route', () => { }) const updatedKnowledgeBase = { ...mockKnowledgeBase, ...validUpdateData } - mockUpdateKnowledgeBase.mockResolvedValueOnce(updatedKnowledgeBase) + mockGetKnowledgeBaseById.mockResolvedValueOnce(mockKnowledgeBase) + mockApplyKnowledgeBaseMetadata.mockResolvedValueOnce(updatedKnowledgeBase) const req = createMockRequest('PUT', validUpdateData) const { PUT } = await import('@/app/api/knowledge/[id]/route') @@ -229,12 +230,12 @@ describe('Knowledge Base By ID API Route', () => { expect(data.success).toBe(true) expect(data.data.name).toBe('Updated Knowledge Base') expect(mockCheckKnowledgeBaseWriteAccess).toHaveBeenCalledWith('kb-123', 'user-123') - expect(mockUpdateKnowledgeBase).toHaveBeenCalledWith( + expect(mockApplyKnowledgeBaseMetadata).toHaveBeenCalledWith( 'kb-123', { name: validUpdateData.name, description: validUpdateData.description, - chunkingConfig: undefined, + chunkingConfig: mockKnowledgeBase.chunkingConfig, }, expect.any(String) ) @@ -304,7 +305,8 @@ describe('Knowledge Base By ID API Route', () => { knowledgeBase: { id: 'kb-123', userId: 'user-123' }, }) - mockUpdateKnowledgeBase.mockRejectedValueOnce(new Error('Database error')) + mockGetKnowledgeBaseById.mockResolvedValueOnce(mockKnowledgeBase) + mockApplyKnowledgeBaseMetadata.mockRejectedValueOnce(new Error('Yjs apply error')) const req = createMockRequest('PUT', validUpdateData) const { PUT } = await import('@/app/api/knowledge/[id]/route') diff --git a/apps/tradinggoose/app/api/knowledge/[id]/route.ts b/apps/tradinggoose/app/api/knowledge/[id]/route.ts index b06d9fbf4..eaf305b1c 100644 --- a/apps/tradinggoose/app/api/knowledge/[id]/route.ts +++ b/apps/tradinggoose/app/api/knowledge/[id]/route.ts @@ -2,12 +2,13 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { + applyKnowledgeBaseMetadata, deleteKnowledgeBase, getKnowledgeBaseById, - updateKnowledgeBase, } from '@/lib/knowledge/service' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' +import { SavedEntityPersistenceError } from '@/lib/yjs/server/apply-entity-state' import { checkKnowledgeBaseAccess, checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' const logger = createLogger('KnowledgeBaseByIdAPI') @@ -17,9 +18,12 @@ const UpdateKnowledgeBaseSchema = z.object({ description: z.string().optional(), chunkingConfig: z .object({ - maxSize: z.number(), - minSize: z.number(), - overlap: z.number(), + maxSize: z.number().min(100).max(4000), + minSize: z.number().min(1).max(2000), + overlap: z.number().min(0).max(500), + }) + .refine((data) => data.minSize < data.maxSize, { + message: 'minSize must be less than maxSize', }) .optional(), }) @@ -95,12 +99,17 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: try { const validatedData = UpdateKnowledgeBaseSchema.parse(body) - const updatedKnowledgeBase = await updateKnowledgeBase( + const currentKnowledgeBase = await getKnowledgeBaseById(id) + if (!currentKnowledgeBase) { + return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 }) + } + + const updatedKnowledgeBase = await applyKnowledgeBaseMetadata( id, { - name: validatedData.name, - description: validatedData.description, - chunkingConfig: validatedData.chunkingConfig, + name: validatedData.name ?? currentKnowledgeBase.name, + description: validatedData.description ?? currentKnowledgeBase.description ?? '', + chunkingConfig: validatedData.chunkingConfig ?? currentKnowledgeBase.chunkingConfig, }, requestId ) @@ -125,6 +134,9 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: } } catch (error) { logger.error(`[${requestId}] Error updating knowledge base`, error) + if (error instanceof SavedEntityPersistenceError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } return NextResponse.json({ error: 'Failed to update knowledge base' }, { status: 500 }) } } diff --git a/apps/tradinggoose/app/api/knowledge/route.ts b/apps/tradinggoose/app/api/knowledge/route.ts index 96df89822..abf3f34e5 100644 --- a/apps/tradinggoose/app/api/knowledge/route.ts +++ b/apps/tradinggoose/app/api/knowledge/route.ts @@ -4,6 +4,7 @@ import { getSession } from '@/lib/auth' import { createKnowledgeBase, getKnowledgeBases } from '@/lib/knowledge/service' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' const logger = createLogger('KnowledgeBaseAPI') @@ -45,13 +46,14 @@ export async function GET(req: NextRequest) { return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 }) } - const knowledgeBasesWithCounts = await getKnowledgeBases(session.user.id, workspaceId) - return NextResponse.json({ success: true, - data: knowledgeBasesWithCounts, + data: await getKnowledgeBases(session.user.id, workspaceId), }) } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } logger.error(`[${requestId}] Error fetching knowledge bases`, error) return NextResponse.json({ error: 'Failed to fetch knowledge bases' }, { status: 500 }) } @@ -100,6 +102,9 @@ export async function POST(req: NextRequest) { throw validationError } } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } logger.error(`[${requestId}] Error creating knowledge base`, error) return NextResponse.json({ error: 'Failed to create knowledge base' }, { status: 500 }) } diff --git a/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts b/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts index ea10bb706..9347d1d5c 100644 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/[id]/refresh/route.ts @@ -4,8 +4,9 @@ import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' import { withMcpAuth } from '@/lib/mcp/middleware' -import { mcpService } from '@/lib/mcp/service' +import { McpServerNotFoundError, mcpService } from '@/lib/mcp/service' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' const logger = createLogger('McpServerRefreshAPI') @@ -31,7 +32,7 @@ export const POST = withMcpAuth('read')( ) const [server] = await db - .select() + .select({ lastConnected: mcpServers.lastConnected }) .from(mcpServers) .where( and( @@ -62,33 +63,46 @@ export const POST = withMcpAuth('read')( `[${requestId}] Successfully connected to server ${serverId}, discovered ${toolCount} tools` ) } catch (error) { + if ( + error instanceof McpServerNotFoundError || + error instanceof SavedEntityRealtimeRequiredError + ) { + throw error + } connectionStatus = 'error' lastError = error instanceof Error ? error.message : 'Connection test failed' logger.warn(`[${requestId}] Failed to connect to server ${serverId}:`, error) } - const [refreshedServer] = await db + const now = new Date() + const lastConnected = connectionStatus === 'connected' ? now : server.lastConnected + await db .update(mcpServers) .set({ - lastToolsRefresh: new Date(), + lastToolsRefresh: now, connectionStatus, lastError, - lastConnected: connectionStatus === 'connected' ? new Date() : server.lastConnected, + lastConnected, toolCount, - updatedAt: new Date(), }) - .where(eq(mcpServers.id, serverId)) - .returning() + .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) logger.info(`[${requestId}] Successfully refreshed MCP server: ${serverId}`) return createMcpSuccessResponse({ status: connectionStatus, toolCount, - lastConnected: refreshedServer?.lastConnected?.toISOString() || null, + lastConnected: lastConnected?.toISOString() ?? null, + lastToolsRefresh: now.toISOString(), error: lastError, }) } catch (error) { logger.error(`[${requestId}] Error refreshing MCP server:`, error) + if (error instanceof McpServerNotFoundError) { + return createMcpErrorResponse(error, 'Server not found', error.status) + } + if (error instanceof SavedEntityRealtimeRequiredError) { + return createMcpErrorResponse(error, error.message, error.status) + } return createMcpErrorResponse( error instanceof Error ? error : new Error('Failed to refresh MCP server'), 'Failed to refresh MCP server', diff --git a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts index 1ebafe741..85061f75b 100644 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts @@ -1,22 +1,24 @@ -import { db } from '@tradinggoose/db' -import { mcpServers } from '@tradinggoose/db/schema' -import { and, eq, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { buildSavedEntityDescriptor } from '@/lib/copilot/review-sessions/identity' +import { verifyReviewTargetAccess } from '@/lib/copilot/review-sessions/permissions' import { createLogger } from '@/lib/logs/console/logger' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' -import { mcpService } from '@/lib/mcp/service' -import { validateMcpServerUrl } from '@/lib/mcp/url-validator' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' -import { savedEntityRowToFields } from '@/lib/yjs/entity-state' -import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' -import { UpdateMcpServerSchema } from '../schema' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' +import { + applySavedEntityState, + SavedEntityPersistenceError, +} from '@/lib/yjs/server/apply-entity-state' +import { readBootstrappedSavedEntityFields } from '@/lib/yjs/server/bootstrap-review-target' +import { RenameMcpServerSchema } from '../schema' const logger = createLogger('McpServerAPI') export const dynamic = 'force-dynamic' /** - * PATCH - Update an MCP server in the workspace (requires write or admin permission) + * PATCH - Rename an MCP server in the workspace (requires write permission). + * Full config edits are saved through the MCP saved-entity Yjs session. */ export const PATCH = withMcpAuth('write')( async ( @@ -29,7 +31,7 @@ export const PATCH = withMcpAuth('write')( try { const rawBody = getParsedBody(request) || (await request.json()) - const parseResult = UpdateMcpServerSchema.safeParse(rawBody) + const parseResult = RenameMcpServerSchema.safeParse(rawBody) if (!parseResult.success) { return createMcpErrorResponse( new Error(`Invalid request body: ${parseResult.error.message}`), @@ -42,46 +44,15 @@ export const PATCH = withMcpAuth('write')( logger.info(`[${requestId}] Updating MCP server: ${serverId} in workspace: ${workspaceId}`, { userId, - updates: Object.keys(body).filter((k) => k !== 'workspaceId'), + updates: ['name'], }) - // Validate URL if being updated - if ( - body.url && - (body.transport === 'http' || - body.transport === 'sse' || - body.transport === 'streamable-http') - ) { - const urlValidation = validateMcpServerUrl(body.url) - if (!urlValidation.isValid) { - return createMcpErrorResponse( - new Error(`Invalid MCP server URL: ${urlValidation.error}`), - 'Invalid server URL', - 400 - ) - } - body.url = urlValidation.normalizedUrl - } - - // Remove workspaceId from body to prevent it from being updated - const { workspaceId: _, ...updateData } = body - - const [updatedServer] = await db - .update(mcpServers) - .set({ - ...updateData, - updatedAt: new Date(), - }) - .where( - and( - eq(mcpServers.id, serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) - ) - ) - .returning() - - if (!updatedServer) { + const access = await verifyReviewTargetAccess( + userId, + buildSavedEntityDescriptor('mcp_server', serverId, workspaceId), + 'write' + ) + if (!access.hasAccess || access.workspaceId !== workspaceId) { return createMcpErrorResponse( new Error('Server not found or access denied'), 'Server not found', @@ -89,18 +60,30 @@ export const PATCH = withMcpAuth('write')( ) } - await applySavedEntityState( + const currentFields = await readBootstrappedSavedEntityFields( 'mcp_server', - updatedServer.id, - savedEntityRowToFields('mcp_server', updatedServer) + serverId, + workspaceId ) - - // Clear MCP service cache after update - mcpService.clearCache(workspaceId) + await applySavedEntityState('mcp_server', serverId, { ...currentFields, name: body.name }) logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`) - return createMcpSuccessResponse({ server: updatedServer }) + return createMcpSuccessResponse({ + server: { + id: serverId, + workspaceId, + name: body.name, + }, + }) } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return createMcpErrorResponse(error, error.message, error.status) + } + + if (error instanceof SavedEntityPersistenceError) { + return createMcpErrorResponse(error, error.message, error.status) + } + logger.error(`[${requestId}] Error updating MCP server:`, error) return createMcpErrorResponse( error instanceof Error ? error : new Error('Failed to update MCP server'), diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index 3bfb06406..aa53a7e0d 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -1,32 +1,25 @@ import { db } from '@tradinggoose/db' import { mcpServers } from '@tradinggoose/db/schema' -import { and, eq, isNull } from 'drizzle-orm' +import { and, eq, inArray, isNull } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { buildSavedEntityDescriptor } from '@/lib/copilot/review-sessions/identity' +import { verifyReviewTargetAccess } from '@/lib/copilot/review-sessions/permissions' import { createLogger } from '@/lib/logs/console/logger' import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware' -import { mcpService } from '@/lib/mcp/service' -import type { McpTransport } from '@/lib/mcp/types' -import { validateMcpServerUrl } from '@/lib/mcp/url-validator' +import { McpServerConfigError, mcpService } from '@/lib/mcp/service' import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' +import { requireSavedEntityRealtimeListMembers } from '@/lib/yjs/server/bootstrap-review-target' import { - applySavedEntityYjsStateToRows, - savedEntityRowToFields, -} from '@/lib/yjs/entity-state' -import { applySavedEntityState } from '@/lib/yjs/server/apply-entity-state' -import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' + deleteYjsSessionInSocketServer, + notifyEntityListMemberRemoved, +} from '@/lib/yjs/server/snapshot-bridge' import { CreateMcpServerSchema } from './schema' const logger = createLogger('McpServersAPI') export const dynamic = 'force-dynamic' -/** - * Check if transport type requires a URL - */ -function isUrlBasedTransport(transport: McpTransport): boolean { - return transport === 'http' || transport === 'sse' || transport === 'streamable-http' -} - /** * GET - List all registered MCP servers for the workspace */ @@ -35,17 +28,60 @@ export const GET = withMcpAuth('read')( try { logger.info(`[${requestId}] Listing MCP servers for workspace ${workspaceId}`) - const rows = await db - .select() - .from(mcpServers) - .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) - const servers = await applySavedEntityYjsStateToRows('mcp_server', rows) + const listMembers = await requireSavedEntityRealtimeListMembers('mcp_server', workspaceId) + const listMemberIds = listMembers.map((member) => member.entityId) + const statusById = new Map( + listMemberIds.length === 0 + ? [] + : ( + await db + .select({ + id: mcpServers.id, + updatedAt: mcpServers.updatedAt, + connectionStatus: mcpServers.connectionStatus, + lastError: mcpServers.lastError, + toolCount: mcpServers.toolCount, + lastConnected: mcpServers.lastConnected, + lastToolsRefresh: mcpServers.lastToolsRefresh, + }) + .from(mcpServers) + .where( + and( + eq(mcpServers.workspaceId, workspaceId), + inArray(mcpServers.id, listMemberIds), + isNull(mcpServers.deletedAt) + ) + ) + ).map((row) => [row.id, row]) + ) + const servers = listMembers.flatMap((server) => { + const status = statusById.get(server.entityId) + if (!status) { + return [] + } + + return { + id: server.entityId, + name: server.entityName, + enabled: server.enabled !== false, + workspaceId, + updatedAt: status.updatedAt?.toISOString(), + connectionStatus: status.connectionStatus, + lastError: status.lastError, + toolCount: status.toolCount, + lastConnected: status.lastConnected?.toISOString(), + lastToolsRefresh: status.lastToolsRefresh?.toISOString(), + } + }) logger.info( `[${requestId}] Listed ${servers.length} MCP servers for workspace ${workspaceId}` ) return createMcpSuccessResponse({ servers }) } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return createMcpErrorResponse(error, error.message, error.status) + } logger.error(`[${requestId}] Error listing MCP servers:`, error) return createMcpErrorResponse( error instanceof Error ? error : new Error('Failed to list MCP servers'), @@ -81,67 +117,35 @@ export const POST = withMcpAuth('write')( workspaceId, }) - if (isUrlBasedTransport(body.transport as McpTransport) && body.url) { - const urlValidation = validateMcpServerUrl(body.url) - if (!urlValidation.isValid) { - return createMcpErrorResponse( - new Error(`Invalid MCP server URL: ${urlValidation.error}`), - 'Invalid server URL', - 400 - ) - } - body.url = urlValidation.normalizedUrl - } - - const serverId = body.id || crypto.randomUUID() - - const [server] = await db - .insert(mcpServers) - .values({ - id: serverId, - workspaceId, - createdBy: userId, - name: body.name, - description: body.description ?? null, - transport: body.transport, - url: body.url ?? null, - headers: body.headers || {}, - command: body.command ?? null, - args: body.args ?? [], - env: body.env ?? {}, - timeout: body.timeout || 30000, - retries: body.retries || 3, - enabled: body.enabled !== false, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning() - - await applySavedEntityState( - 'mcp_server', - server.id, - savedEntityRowToFields('mcp_server', server) - ) - - mcpService.clearCache(workspaceId) + const created = await mcpService.createWorkspaceServer({ + userId, + workspaceId, + fields: body, + }) - logger.info(`[${requestId}] Successfully registered MCP server: ${body.name}`) + logger.info(`[${requestId}] Successfully registered MCP server: ${created.fields.name}`) // Track MCP server registration try { const { trackPlatformEvent } = await import('@/lib/telemetry/tracer') trackPlatformEvent('platform.mcp.server_added', { - 'mcp.server_id': serverId, - 'mcp.server_name': body.name, - 'mcp.transport': body.transport, + 'mcp.server_id': created.entityId, + 'mcp.server_name': String(created.fields.name ?? ''), + 'mcp.transport': String(created.fields.transport ?? ''), 'workspace.id': workspaceId, }) } catch (_e) { // Silently fail } - return createMcpSuccessResponse({ serverId }, 201) + return createMcpSuccessResponse({ serverId: created.entityId }, 201) } catch (error) { + if (error instanceof McpServerConfigError) { + return createMcpErrorResponse(error, error.message, error.status) + } + if (error instanceof SavedEntityRealtimeRequiredError) { + return createMcpErrorResponse(error, error.message, error.status) + } logger.error(`[${requestId}] Error registering MCP server:`, error) return createMcpErrorResponse( error instanceof Error ? error : new Error('Failed to register MCP server'), @@ -171,13 +175,12 @@ export const DELETE = withMcpAuth('write')( logger.info(`[${requestId}] Deleting MCP server: ${serverId} from workspace: ${workspaceId}`) - const [server] = await db - .select({ id: mcpServers.id }) - .from(mcpServers) - .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) - .limit(1) - - if (!server) { + const access = await verifyReviewTargetAccess( + userId, + buildSavedEntityDescriptor('mcp_server', serverId, workspaceId), + 'write' + ) + if (!access.hasAccess || access.workspaceId !== workspaceId) { return createMcpErrorResponse( new Error('Server not found or access denied'), 'Server not found', @@ -185,15 +188,25 @@ export const DELETE = withMcpAuth('write')( ) } - await deleteYjsSessionInSocketServer(serverId) await db .delete(mcpServers) - .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) + .where( + and( + eq(mcpServers.id, serverId), + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt) + ) + ) - mcpService.clearCache(workspaceId) + await Promise.allSettled([ + deleteYjsSessionInSocketServer(serverId), + notifyEntityListMemberRemoved('mcp_server', workspaceId, serverId), + ]) logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`) - return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` }) + return createMcpSuccessResponse({ + message: `Server ${serverId} deleted successfully`, + }) } catch (error) { logger.error(`[${requestId}] Error deleting MCP server:`, error) return createMcpErrorResponse( diff --git a/apps/tradinggoose/app/api/mcp/servers/schema.ts b/apps/tradinggoose/app/api/mcp/servers/schema.ts index 9da0e2a8b..f877c3ae4 100644 --- a/apps/tradinggoose/app/api/mcp/servers/schema.ts +++ b/apps/tradinggoose/app/api/mcp/servers/schema.ts @@ -1,13 +1,9 @@ import { z } from 'zod' -/** - * Base schema for MCP server fields shared between create and update operations. - * `name` and `transport` are required here; the update schema derives from this via `.partial()`. - */ const McpServerBaseSchema = z.object({ - name: z.string().min(1), + name: z.string().trim().min(1), description: z.string().optional(), - transport: z.string().min(1), + transport: z.enum(['http', 'sse', 'streamable-http']), url: z.string().optional(), headers: z.record(z.string()).optional(), command: z.string().optional(), @@ -18,22 +14,14 @@ const McpServerBaseSchema = z.object({ enabled: z.boolean().optional(), }) -/** - * Schema for creating a new MCP server. - * `name` and `transport` are required; `id` is optional (auto-generated if omitted). - */ -export const CreateMcpServerSchema = McpServerBaseSchema.extend({ - id: z.string().optional(), -}) +export const CreateMcpServerSchema = McpServerBaseSchema.refine( + (server) => server.enabled === false || !!server.url?.trim(), + { + message: 'URL is required when an MCP server is enabled', + path: ['url'], + } +) -/** - * Schema for updating an existing MCP server. - * All fields are optional. `description`, `url`, and `command` additionally accept null - * so that clients can explicitly clear those fields. - */ -export const UpdateMcpServerSchema = McpServerBaseSchema.partial().extend({ - description: z.string().optional().nullable(), - url: z.string().optional().nullable(), - command: z.string().optional().nullable(), - workspaceId: z.string().optional(), -}) +export const RenameMcpServerSchema = z.object({ + name: z.string().trim().min(1), +}).strict() diff --git a/apps/tradinggoose/app/api/mcp/tools/discover/route.ts b/apps/tradinggoose/app/api/mcp/tools/discover/route.ts index 8ae3dfb59..380d7dbbf 100644 --- a/apps/tradinggoose/app/api/mcp/tools/discover/route.ts +++ b/apps/tradinggoose/app/api/mcp/tools/discover/route.ts @@ -17,19 +17,17 @@ export const GET = withMcpAuth('read')( try { const { searchParams } = new URL(request.url) const serverId = searchParams.get('serverId') - const forceRefresh = searchParams.get('refresh') === 'true' logger.info(`[${requestId}] Discovering MCP tools for user ${userId}`, { serverId, workspaceId, - forceRefresh, }) let tools if (serverId) { tools = await mcpService.discoverServerTools(userId, serverId, workspaceId) } else { - tools = await mcpService.discoverTools(userId, workspaceId, forceRefresh) + tools = await mcpService.discoverTools(userId, workspaceId) } const byServer: Record = {} diff --git a/apps/tradinggoose/app/api/monitors/[id]/route.ts b/apps/tradinggoose/app/api/monitors/[id]/route.ts index 1ad271d75..505c99508 100644 --- a/apps/tradinggoose/app/api/monitors/[id]/route.ts +++ b/apps/tradinggoose/app/api/monitors/[id]/route.ts @@ -1,66 +1,24 @@ -import { isDeepStrictEqual } from 'node:util' import { db, webhook } from '@tradinggoose/db' import { and, eq, inArray } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { - type IndicatorMonitorProviderConfig, - IndicatorMonitorUpdateSchema, - normalizeIndicatorMonitorConfig, -} from '@/lib/indicators/monitor-config' import { createLogger } from '@/lib/logs/console/logger' -import { - normalizePortfolioMonitorConfig, - type PortfolioMonitorProviderConfig, - PortfolioMonitorUpdateSchema, -} from '@/lib/monitors/portfolio-config' -import { - getMonitorTriggerIdForProvider, - MONITOR_WEBHOOK_PROVIDERS, - type MonitorWebhookProvider, - PORTFOLIO_MONITOR_PROVIDER, -} from '@/lib/monitors/sources' +import { MONITOR_WEBHOOK_PROVIDERS } from '@/lib/monitors/sources' import { generateRequestId } from '@/lib/utils' import { authenticateIndicatorRequest, checkWorkspacePermission } from '@/app/api/indicators/utils' import { notifyMonitorsReconcile } from '@/app/api/monitors/reconcile' -import { getTradingProviderOAuthServiceId } from '@/providers/trading/providers' -import type { TradingProviderId } from '@/providers/trading/types' import { - ensureMonitorTriggerBlockInDeployedState, - ensureTriggerCapableIndicator, - ensureWorkflowInWorkspace, getMonitorRowById, isMonitorClientError, - loadIndicatorInputMetadata, MonitorRequestError, - resolvePortfolioMonitorAccount, toMonitorRecord, } from '../shared' +import { updateMonitorForUser } from '../update-service' const logger = createLogger('MonitorByIdAPI') export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -type IndicatorUpdatePayload = ReturnType -type PortfolioUpdatePayload = ReturnType -type MonitorUpdatePayload = IndicatorUpdatePayload | PortfolioUpdatePayload - -const parseUpdatePayload = ( - source: MonitorWebhookProvider, - body: unknown -): MonitorUpdatePayload => { - const parsed = - source === PORTFOLIO_MONITOR_PROVIDER - ? PortfolioMonitorUpdateSchema.safeParse(body) - : IndicatorMonitorUpdateSchema.safeParse(body) - - if (!parsed.success) { - throw new MonitorRequestError(parsed.error.errors[0]?.message ?? 'Invalid request') - } - - return parsed.data -} - export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const requestId = generateRequestId() @@ -112,100 +70,16 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< if ('response' in auth) return auth.response const { id } = await params - const row = await getMonitorRowById(id) - if (!row) { - return NextResponse.json({ error: 'Monitor not found' }, { status: 404 }) - } - const body = await request.json().catch(() => ({})) - const source = row.webhook.provider as MonitorWebhookProvider - const payload = parseUpdatePayload(source, body) - const workspaceId = row.workflow.workspaceId - if (!workspaceId) { - return NextResponse.json({ error: 'Monitor workspace is missing' }, { status: 400 }) - } - if (payload.workspaceId !== workspaceId) { - return NextResponse.json( - { error: 'workspaceId does not match monitor workspace' }, - { status: 400 } - ) - } - - const permission = await checkWorkspacePermission({ - userId: auth.userId, - workspaceId, - requireWrite: true, - responseShape: 'errorOnly', - }) - if (!permission.ok) return permission.response - - const existingConfig = row.webhook.providerConfig as - | IndicatorMonitorProviderConfig - | PortfolioMonitorProviderConfig - const existingMonitor = existingConfig.monitor - if (!existingMonitor) { - return NextResponse.json({ error: 'Invalid existing monitor config' }, { status: 500 }) - } - - const nextWorkflowId = payload.workflowId ?? row.webhook.workflowId - const nextTriggerBlockId = payload.blockId ?? existingMonitor.triggerBlockId - if (!nextTriggerBlockId) { - return NextResponse.json({ error: 'blockId is required' }, { status: 400 }) - } - - const workflowRow = await ensureWorkflowInWorkspace(nextWorkflowId, workspaceId) - if ( - payload.blockId !== undefined || - payload.workflowId !== undefined || - payload.isActive === true - ) { - await ensureMonitorTriggerBlockInDeployedState( - nextWorkflowId, - nextTriggerBlockId, - getMonitorTriggerIdForProvider(source) - ) - } - const nextIsActive = - payload.isActive === undefined - ? row.webhook.isActive - : payload.isActive && workflowRow.isDeployed - - const providerConfig = await buildProviderConfigForUpdate({ - source, - payload, - existingConfig, - nextTriggerBlockId, - workspaceId, + const updatedMonitor = await updateMonitorForUser({ + monitorId: id, userId: auth.userId, + body, requestId, - requireCompleteAuth: nextIsActive, + logger, }) - const [updatedMonitor] = await db - .update(webhook) - .set({ - workflowId: nextWorkflowId, - blockId: null, - providerConfig, - isActive: nextIsActive, - updatedAt: new Date(), - }) - .where( - and( - eq(webhook.id, id), - eq(webhook.provider, source), - eq(webhook.workflowId, row.workflow.id) - ) - ) - .returning() - - void notifyMonitorsReconcile({ requestId, logger }) - - if (!updatedMonitor) { - return NextResponse.json({ error: 'Monitor not found' }, { status: 404 }) - } - - return NextResponse.json({ data: await toMonitorRecord(updatedMonitor) }, { status: 200 }) + return NextResponse.json({ data: updatedMonitor }, { status: 200 }) } catch (error) { const message = error instanceof Error ? error.message : 'Internal server error' logger.error(`[${requestId}] Failed to update monitor`, { error }) @@ -269,127 +143,3 @@ export async function DELETE( return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } - -async function buildProviderConfigForUpdate({ - source, - payload, - existingConfig, - nextTriggerBlockId, - workspaceId, - userId, - requestId, - requireCompleteAuth, -}: { - source: MonitorWebhookProvider - payload: MonitorUpdatePayload - existingConfig: IndicatorMonitorProviderConfig | PortfolioMonitorProviderConfig - nextTriggerBlockId: string - workspaceId: string - userId: string - requestId: string - requireCompleteAuth: boolean -}) { - if (source === PORTFOLIO_MONITOR_PROVIDER) { - const portfolioPayload = payload as PortfolioUpdatePayload - const portfolioConfig = existingConfig as PortfolioMonitorProviderConfig - const existingMonitor = portfolioConfig.monitor - const nextProviderId = portfolioPayload.providerId ?? existingMonitor.providerId - const nextCredentialId = portfolioPayload.credentialId ?? existingMonitor.credentialId - const nextAccountId = portfolioPayload.accountId ?? existingMonitor.accountId - const requestedServiceId = portfolioPayload.serviceId ?? existingMonitor.serviceId - const requestedOAuthServiceId = getTradingProviderOAuthServiceId( - nextProviderId as TradingProviderId, - requestedServiceId - ) - if (!requestedOAuthServiceId) { - throw new MonitorRequestError('Trading provider connection is required') - } - const connectionChanged = - nextProviderId !== existingMonitor.providerId || - requestedOAuthServiceId !== existingMonitor.serviceId || - nextCredentialId !== existingMonitor.credentialId || - nextAccountId !== existingMonitor.accountId - const connection = - requireCompleteAuth || connectionChanged - ? await resolvePortfolioMonitorAccount({ - userId, - providerId: nextProviderId, - serviceId: requestedOAuthServiceId, - credentialId: nextCredentialId, - accountId: nextAccountId, - requestId, - }) - : { - serviceId: existingMonitor.serviceId, - connectionOwnerUserId: existingMonitor.connectionOwnerUserId, - } - - const providerConfig = normalizePortfolioMonitorConfig({ - triggerBlockId: nextTriggerBlockId, - providerId: nextProviderId, - serviceId: connection.serviceId, - credentialId: nextCredentialId, - connectionOwnerUserId: connection.connectionOwnerUserId, - accountId: nextAccountId, - condition: portfolioPayload.condition ?? existingMonitor.condition, - fireMode: portfolioPayload.fireMode ?? existingMonitor.fireMode, - cooldownSeconds: portfolioPayload.cooldownSeconds ?? existingMonitor.cooldownSeconds, - pollIntervalSeconds: - portfolioPayload.pollIntervalSeconds ?? existingMonitor.pollIntervalSeconds, - }) - const shouldPreserveRuntimeState = isDeepStrictEqual( - providerConfig.monitor, - portfolioConfig.monitor - ) - if (shouldPreserveRuntimeState && portfolioConfig.runtimeState !== undefined) { - providerConfig.runtimeState = portfolioConfig.runtimeState - } - return providerConfig - } - - const indicatorPayload = payload as IndicatorUpdatePayload - const existingMonitor = (existingConfig as IndicatorMonitorProviderConfig).monitor - const nextProviderId = indicatorPayload.providerId ?? existingMonitor.providerId - const providerChanged = nextProviderId !== existingMonitor.providerId - const nextIndicatorId = indicatorPayload.indicatorId ?? existingMonitor.indicatorId - const indicatorChanged = nextIndicatorId !== existingMonitor.indicatorId - const authProvided = Object.hasOwn(indicatorPayload, 'auth') - const providerParamsProvided = Object.hasOwn(indicatorPayload, 'providerParams') - const indicatorInputsProvided = Object.hasOwn(indicatorPayload, 'indicatorInputs') - const shouldNormalizeIndicatorInputs = indicatorInputsProvided || indicatorChanged - - await ensureTriggerCapableIndicator(workspaceId, nextIndicatorId) - const indicatorMetadata = shouldNormalizeIndicatorInputs - ? await loadIndicatorInputMetadata(workspaceId, nextIndicatorId) - : null - const nextProviderParams = providerChanged - ? providerParamsProvided - ? (indicatorPayload.providerParams ?? {}) - : undefined - : providerParamsProvided - ? (indicatorPayload.providerParams ?? {}) - : existingMonitor.providerParams - const nextIndicatorInputs = shouldNormalizeIndicatorInputs - ? indicatorInputsProvided - ? (indicatorPayload.indicatorInputs ?? {}) - : {} - : undefined - - const providerConfig = await normalizeIndicatorMonitorConfig({ - triggerBlockId: nextTriggerBlockId, - providerId: nextProviderId, - interval: indicatorPayload.interval ?? existingMonitor.interval, - listingInput: indicatorPayload.listing ?? existingMonitor.listing, - indicatorId: nextIndicatorId, - authInput: authProvided ? indicatorPayload.auth : undefined, - providerParams: nextProviderParams, - indicatorInputs: nextIndicatorInputs, - indicatorInputMeta: indicatorMetadata?.inputMeta, - previousAuth: providerChanged ? undefined : existingMonitor.auth, - requireCompleteAuth, - }) - if (!shouldNormalizeIndicatorInputs && typeof existingMonitor.indicatorInputs !== 'undefined') { - providerConfig.monitor.indicatorInputs = existingMonitor.indicatorInputs - } - return providerConfig -} diff --git a/apps/tradinggoose/app/api/monitors/shared.ts b/apps/tradinggoose/app/api/monitors/shared.ts index a2e0b5b5b..d813478f6 100644 --- a/apps/tradinggoose/app/api/monitors/shared.ts +++ b/apps/tradinggoose/app/api/monitors/shared.ts @@ -35,7 +35,6 @@ import { resolveTradingProviderSelectedAccount, } from '@/lib/trading/context' import { isTradingServiceError } from '@/lib/trading/errors' -import { applySavedEntityYjsStateToRows } from '@/lib/yjs/entity-state' type WebhookRow = typeof webhook.$inferSelect @@ -272,7 +271,6 @@ export const ensureTriggerCapableIndicator = async (workspaceId: string, indicat .from(pineIndicators) .where(and(eq(pineIndicators.id, indicatorId), eq(pineIndicators.workspaceId, workspaceId))) .limit(1) - .then((rows) => applySavedEntityYjsStateToRows('indicator', rows)) const customIndicator = customRows[0] if (!customIndicator) { @@ -306,7 +304,6 @@ export const loadIndicatorInputMetadata = async ( .from(pineIndicators) .where(and(eq(pineIndicators.id, indicatorId), eq(pineIndicators.workspaceId, workspaceId))) .limit(1) - .then((rows) => applySavedEntityYjsStateToRows('indicator', rows)) const row = rows[0] if (!row) { diff --git a/apps/tradinggoose/app/api/monitors/update-service.ts b/apps/tradinggoose/app/api/monitors/update-service.ts new file mode 100644 index 000000000..688a41895 --- /dev/null +++ b/apps/tradinggoose/app/api/monitors/update-service.ts @@ -0,0 +1,282 @@ +import { isDeepStrictEqual } from 'node:util' +import { db, webhook } from '@tradinggoose/db' +import { and, eq } from 'drizzle-orm' +import { + type IndicatorMonitorProviderConfig, + IndicatorMonitorUpdateSchema, + normalizeIndicatorMonitorConfig, +} from '@/lib/indicators/monitor-config' +import { + normalizePortfolioMonitorConfig, + type PortfolioMonitorProviderConfig, + PortfolioMonitorUpdateSchema, +} from '@/lib/monitors/portfolio-config' +import { + getMonitorTriggerIdForProvider, + type MonitorWebhookProvider, + PORTFOLIO_MONITOR_PROVIDER, +} from '@/lib/monitors/sources' +import { checkWorkspaceAccess } from '@/lib/permissions/utils' +import { getTradingProviderOAuthServiceId } from '@/providers/trading/providers' +import type { TradingProviderId } from '@/providers/trading/types' +import { notifyMonitorsReconcile } from '@/app/api/monitors/reconcile' +import { + ensureMonitorTriggerBlockInDeployedState, + ensureTriggerCapableIndicator, + ensureWorkflowInWorkspace, + getMonitorRowById, + loadIndicatorInputMetadata, + MonitorRequestError, + resolvePortfolioMonitorAccount, + toMonitorRecord, +} from './shared' + +type IndicatorUpdatePayload = ReturnType +type PortfolioUpdatePayload = ReturnType +type MonitorUpdatePayload = IndicatorUpdatePayload | PortfolioUpdatePayload +type MonitorUpdateLogger = { + warn: (message: string, ...args: unknown[]) => void +} + +const parseUpdatePayload = ( + source: MonitorWebhookProvider, + body: unknown +): MonitorUpdatePayload => { + const parsed = + source === PORTFOLIO_MONITOR_PROVIDER + ? PortfolioMonitorUpdateSchema.safeParse(body) + : IndicatorMonitorUpdateSchema.safeParse(body) + + if (!parsed.success) { + throw new MonitorRequestError(parsed.error.errors[0]?.message ?? 'Invalid request') + } + + return parsed.data +} + +export async function updateMonitorForUser({ + monitorId, + userId, + body, + requestId, + logger, +}: { + monitorId: string + userId: string + body: unknown + requestId: string + logger: MonitorUpdateLogger +}) { + const row = await getMonitorRowById(monitorId) + if (!row) { + throw new MonitorRequestError('Monitor not found', 404) + } + + const source = row.webhook.provider as MonitorWebhookProvider + const payload = parseUpdatePayload(source, body) + const workspaceId = row.workflow.workspaceId + if (!workspaceId) { + throw new MonitorRequestError('Monitor workspace is missing', 400) + } + if (payload.workspaceId !== workspaceId) { + throw new MonitorRequestError('workspaceId does not match monitor workspace', 400) + } + + const access = await checkWorkspaceAccess(workspaceId, userId) + if (!access.exists || !access.hasAccess) { + throw new MonitorRequestError('Access denied', 403) + } + if (!access.canWrite) { + throw new MonitorRequestError('Write permission required', 403) + } + + const existingConfig = row.webhook.providerConfig as + | IndicatorMonitorProviderConfig + | PortfolioMonitorProviderConfig + const existingMonitor = existingConfig.monitor + if (!existingMonitor) { + throw new Error('Invalid existing monitor config') + } + + const nextWorkflowId = payload.workflowId ?? row.webhook.workflowId + const nextTriggerBlockId = payload.blockId ?? existingMonitor.triggerBlockId + if (!nextTriggerBlockId) { + throw new MonitorRequestError('blockId is required', 400) + } + + const workflowRow = await ensureWorkflowInWorkspace(nextWorkflowId, workspaceId) + if ( + payload.blockId !== undefined || + payload.workflowId !== undefined || + payload.isActive === true + ) { + await ensureMonitorTriggerBlockInDeployedState( + nextWorkflowId, + nextTriggerBlockId, + getMonitorTriggerIdForProvider(source) + ) + } + const nextIsActive = + payload.isActive === undefined ? row.webhook.isActive : payload.isActive && workflowRow.isDeployed + + const providerConfig = await buildProviderConfigForUpdate({ + source, + payload, + existingConfig, + nextTriggerBlockId, + workspaceId, + userId, + requestId, + requireCompleteAuth: nextIsActive, + }) + + const [updatedMonitor] = await db + .update(webhook) + .set({ + workflowId: nextWorkflowId, + blockId: null, + providerConfig, + isActive: nextIsActive, + updatedAt: new Date(), + }) + .where( + and( + eq(webhook.id, monitorId), + eq(webhook.provider, source), + eq(webhook.workflowId, row.workflow.id) + ) + ) + .returning() + + void notifyMonitorsReconcile({ requestId, logger }) + + if (!updatedMonitor) { + throw new MonitorRequestError('Monitor not found', 404) + } + + return toMonitorRecord(updatedMonitor) +} + +async function buildProviderConfigForUpdate({ + source, + payload, + existingConfig, + nextTriggerBlockId, + workspaceId, + userId, + requestId, + requireCompleteAuth, +}: { + source: MonitorWebhookProvider + payload: MonitorUpdatePayload + existingConfig: IndicatorMonitorProviderConfig | PortfolioMonitorProviderConfig + nextTriggerBlockId: string + workspaceId: string + userId: string + requestId: string + requireCompleteAuth: boolean +}) { + if (source === PORTFOLIO_MONITOR_PROVIDER) { + const portfolioPayload = payload as PortfolioUpdatePayload + const portfolioConfig = existingConfig as PortfolioMonitorProviderConfig + const existingMonitor = portfolioConfig.monitor + const nextProviderId = portfolioPayload.providerId ?? existingMonitor.providerId + const nextCredentialId = portfolioPayload.credentialId ?? existingMonitor.credentialId + const nextAccountId = portfolioPayload.accountId ?? existingMonitor.accountId + const requestedServiceId = portfolioPayload.serviceId ?? existingMonitor.serviceId + const requestedOAuthServiceId = getTradingProviderOAuthServiceId( + nextProviderId as TradingProviderId, + requestedServiceId + ) + if (!requestedOAuthServiceId) { + throw new MonitorRequestError('Trading provider connection is required') + } + const connectionChanged = + nextProviderId !== existingMonitor.providerId || + requestedOAuthServiceId !== existingMonitor.serviceId || + nextCredentialId !== existingMonitor.credentialId || + nextAccountId !== existingMonitor.accountId + const connection = + requireCompleteAuth || connectionChanged + ? await resolvePortfolioMonitorAccount({ + userId, + providerId: nextProviderId, + serviceId: requestedOAuthServiceId, + credentialId: nextCredentialId, + accountId: nextAccountId, + requestId, + }) + : { + serviceId: existingMonitor.serviceId, + connectionOwnerUserId: existingMonitor.connectionOwnerUserId, + } + + const providerConfig = normalizePortfolioMonitorConfig({ + triggerBlockId: nextTriggerBlockId, + providerId: nextProviderId, + serviceId: connection.serviceId, + credentialId: nextCredentialId, + connectionOwnerUserId: connection.connectionOwnerUserId, + accountId: nextAccountId, + condition: portfolioPayload.condition ?? existingMonitor.condition, + fireMode: portfolioPayload.fireMode ?? existingMonitor.fireMode, + cooldownSeconds: portfolioPayload.cooldownSeconds ?? existingMonitor.cooldownSeconds, + pollIntervalSeconds: + portfolioPayload.pollIntervalSeconds ?? existingMonitor.pollIntervalSeconds, + }) + const shouldPreserveRuntimeState = isDeepStrictEqual( + providerConfig.monitor, + portfolioConfig.monitor + ) + if (shouldPreserveRuntimeState && portfolioConfig.runtimeState !== undefined) { + providerConfig.runtimeState = portfolioConfig.runtimeState + } + return providerConfig + } + + const indicatorPayload = payload as IndicatorUpdatePayload + const existingMonitor = (existingConfig as IndicatorMonitorProviderConfig).monitor + const nextProviderId = indicatorPayload.providerId ?? existingMonitor.providerId + const providerChanged = nextProviderId !== existingMonitor.providerId + const nextIndicatorId = indicatorPayload.indicatorId ?? existingMonitor.indicatorId + const indicatorChanged = nextIndicatorId !== existingMonitor.indicatorId + const authProvided = Object.hasOwn(indicatorPayload, 'auth') + const providerParamsProvided = Object.hasOwn(indicatorPayload, 'providerParams') + const indicatorInputsProvided = Object.hasOwn(indicatorPayload, 'indicatorInputs') + const shouldNormalizeIndicatorInputs = indicatorInputsProvided || indicatorChanged + + await ensureTriggerCapableIndicator(workspaceId, nextIndicatorId) + const indicatorMetadata = shouldNormalizeIndicatorInputs + ? await loadIndicatorInputMetadata(workspaceId, nextIndicatorId) + : null + const nextProviderParams = providerChanged + ? providerParamsProvided + ? (indicatorPayload.providerParams ?? {}) + : undefined + : providerParamsProvided + ? (indicatorPayload.providerParams ?? {}) + : existingMonitor.providerParams + const nextIndicatorInputs = shouldNormalizeIndicatorInputs + ? indicatorInputsProvided + ? (indicatorPayload.indicatorInputs ?? {}) + : {} + : undefined + + const providerConfig = await normalizeIndicatorMonitorConfig({ + triggerBlockId: nextTriggerBlockId, + providerId: nextProviderId, + interval: indicatorPayload.interval ?? existingMonitor.interval, + listingInput: indicatorPayload.listing ?? existingMonitor.listing, + indicatorId: nextIndicatorId, + authInput: authProvided ? indicatorPayload.auth : undefined, + providerParams: nextProviderParams, + indicatorInputs: nextIndicatorInputs, + indicatorInputMeta: indicatorMetadata?.inputMeta, + previousAuth: providerChanged ? undefined : existingMonitor.auth, + requireCompleteAuth, + }) + if (!shouldNormalizeIndicatorInputs && typeof existingMonitor.indicatorInputs !== 'undefined') { + providerConfig.monitor.indicatorInputs = existingMonitor.indicatorInputs + } + return providerConfig +} diff --git a/apps/tradinggoose/app/api/providers/trading/order/route.test.ts b/apps/tradinggoose/app/api/providers/trading/order/route.test.ts index ff09260cd..e02f8a4cc 100644 --- a/apps/tradinggoose/app/api/providers/trading/order/route.test.ts +++ b/apps/tradinggoose/app/api/providers/trading/order/route.test.ts @@ -3,7 +3,7 @@ */ import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { TradingServiceError } from '@/lib/trading/errors' import { createMockRequest } from '@/app/api/__test-utils__/utils' @@ -18,6 +18,7 @@ const mockUpdateOrderHistoryResult = vi.fn() const mockFetch = vi.fn() const idempotencyStore = new Map() let idempotencyCounter = 0 +let routePost: typeof import('@/app/api/providers/trading/order/route').POST vi.mock('@/lib/logs/console/logger', () => ({ createLogger: () => ({ @@ -73,6 +74,10 @@ vi.mock('@/providers/trading/portfolio', async () => { } }) +beforeAll(async () => { + ;({ POST: routePost } = await import('@/app/api/providers/trading/order/route')) +}) + const stockListing = { listing_type: 'default', listing_id: 'AAPL', @@ -211,8 +216,7 @@ describe('Trading provider order route', () => { }) it('rejects invalid JSON before auth or broker calls', async () => { - const { POST } = await import('@/app/api/providers/trading/order/route') - const response = await POST( + const response = await routePost( new NextRequest('http://localhost:3000/api/providers/trading/order', { method: 'POST', body: '{', diff --git a/apps/tradinggoose/app/api/skills/import/route.ts b/apps/tradinggoose/app/api/skills/import/route.ts index c293868c8..6320f54ce 100644 --- a/apps/tradinggoose/app/api/skills/import/route.ts +++ b/apps/tradinggoose/app/api/skills/import/route.ts @@ -6,6 +6,7 @@ import { getUserEntityPermissions } from '@/lib/permissions/utils' import { parseImportedSkillsFile } from '@/lib/skills/import-export' import { importSkills } from '@/lib/skills/operations' import { generateRequestId } from '@/lib/utils' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' const logger = createLogger('SkillsImportAPI') @@ -60,6 +61,9 @@ export async function POST(request: NextRequest) { }, }) } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } if (error instanceof z.ZodError) { logger.warn(`[${requestId}] Invalid skills import data`, { errors: error.errors }) const workspaceError = error.errors.find( diff --git a/apps/tradinggoose/app/api/skills/route.test.ts b/apps/tradinggoose/app/api/skills/route.test.ts index c327d6ed2..bfcf5fa99 100644 --- a/apps/tradinggoose/app/api/skills/route.test.ts +++ b/apps/tradinggoose/app/api/skills/route.test.ts @@ -6,7 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mockCheckHybridAuth = vi.fn() const mockGetUserEntityPermissions = vi.fn() -const mockUpsertSkills = vi.fn() +const mockCreateSkills = vi.fn() +const mockSaveSkill = vi.fn() const mockListSkills = vi.fn() const mockDeleteSkill = vi.fn() @@ -19,7 +20,8 @@ vi.mock('@/lib/permissions/utils', () => ({ })) vi.mock('@/lib/skills/operations', () => ({ - upsertSkills: mockUpsertSkills, + createSkills: mockCreateSkills, + saveSkill: mockSaveSkill, listSkills: mockListSkills, deleteSkill: mockDeleteSkill, })) @@ -45,7 +47,8 @@ describe('Skills API Routes', () => { vi.resetAllMocks() mockCheckHybridAuth.mockResolvedValue({ success: true, userId: 'user-123' }) mockGetUserEntityPermissions.mockResolvedValue('admin') - mockUpsertSkills.mockResolvedValue([]) + mockCreateSkills.mockResolvedValue([]) + mockSaveSkill.mockResolvedValue([]) mockListSkills.mockResolvedValue([]) mockDeleteSkill.mockResolvedValue(true) }) @@ -99,7 +102,7 @@ describe('Skills API Routes', () => { }) it('POST should accept human-readable skill names', async () => { - mockUpsertSkills.mockResolvedValue([ + mockCreateSkills.mockResolvedValue([ { id: 'skill-1', name: 'Market Research', diff --git a/apps/tradinggoose/app/api/skills/route.ts b/apps/tradinggoose/app/api/skills/route.ts index 6d216850e..6433e18db 100644 --- a/apps/tradinggoose/app/api/skills/route.ts +++ b/apps/tradinggoose/app/api/skills/route.ts @@ -8,8 +8,10 @@ import { SKILL_DESCRIPTION_MAX_LENGTH, SKILL_NAME_MAX_LENGTH, } from '@/lib/skills/import-export' -import { deleteSkill, listSkills, upsertSkills } from '@/lib/skills/operations' +import { createSkills, deleteSkill, listSkills, saveSkill } from '@/lib/skills/operations' import { generateRequestId } from '@/lib/utils' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' +import { SavedEntityPersistenceError } from '@/lib/yjs/server/apply-entity-state' const logger = createLogger('SkillsAPI') @@ -57,9 +59,11 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - const result = await listSkills({ workspaceId }) - return NextResponse.json({ data: result }, { status: 200 }) + return NextResponse.json({ data: await listSkills({ workspaceId }) }, { status: 200 }) } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } logger.error(`[${requestId}] Error fetching skills:`, error) return NextResponse.json({ error: 'Failed to fetch skills' }, { status: 500 }) } @@ -95,12 +99,36 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) } - const resultSkills = await upsertSkills({ - skills, - workspaceId, - userId: authResult.userId, - requestId, - }) + const skillsToCreate = skills.filter((skill) => !skill.id) + const skillsToSave = skills.filter((skill) => skill.id) + if (skillsToCreate.length > 0 && skillsToSave.length > 0) { + return NextResponse.json( + { error: 'Create and save skills in separate requests' }, + { status: 400 } + ) + } + if (skillsToSave.length > 1) { + return NextResponse.json({ error: 'Save one existing skill per request' }, { status: 400 }) + } + + const resultSkills = + skillsToSave.length === 1 + ? await saveSkill({ + skill: { + id: skillsToSave[0].id!, + name: skillsToSave[0].name, + description: skillsToSave[0].description, + content: skillsToSave[0].content, + }, + workspaceId, + requestId, + }) + : await createSkills({ + skills: skillsToCreate, + workspaceId, + userId: authResult.userId, + requestId, + }) return NextResponse.json({ success: true, data: resultSkills }) } catch (validationError) { @@ -119,13 +147,22 @@ export async function POST(request: NextRequest) { ) } + if (validationError instanceof SavedEntityPersistenceError) { + return NextResponse.json(validationError.responseBody(), { status: validationError.status }) + } if (validationError instanceof Error && validationError.message.includes('already exists')) { return NextResponse.json({ error: validationError.message }, { status: 409 }) } + if (validationError instanceof Error && validationError.message.includes('was not found')) { + return NextResponse.json({ error: validationError.message }, { status: 404 }) + } throw validationError } } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } logger.error(`[${requestId}] Error updating skills`, error) return NextResponse.json({ error: 'Failed to update skills' }, { status: 500 }) } diff --git a/apps/tradinggoose/app/api/templates/[id]/use/route.ts b/apps/tradinggoose/app/api/templates/[id]/use/route.ts index e8474a355..c8f635eb5 100644 --- a/apps/tradinggoose/app/api/templates/[id]/use/route.ts +++ b/apps/tradinggoose/app/api/templates/[id]/use/route.ts @@ -6,9 +6,11 @@ import { v4 as uuidv4 } from 'uuid' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { getBaseUrl } from '@/lib/urls/utils' +import { regenerateWorkflowStateIds } from '@/lib/workflows/db-helpers' +import { remapVariableIds } from '@/lib/workflows/import-export' import { normalizeVariables } from '@/lib/workflows/variable-utils' -import { regenerateWorkflowStateIds, remapVariableIds } from '@/lib/workflows/db-helpers' +import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' +import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' const logger = createLogger('TemplateUseAPI') @@ -65,51 +67,49 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const now = new Date() const templateState = - templateData.state && typeof templateData.state === 'object' ? (templateData.state as any) : null + templateData.state && typeof templateData.state === 'object' + ? (templateData.state as any) + : null + if ( + !templateState || + typeof templateState.blocks !== 'object' || + !templateState.blocks || + !Array.isArray(templateState.edges) + ) { + return NextResponse.json({ error: 'Template workflow state is missing' }, { status: 409 }) + } const templateVariables = normalizeVariables(templateState?.variables) const remappedVariables = remapVariableIds(templateVariables, newWorkflowId) + const workflowName = `${templateData.name} (copy)` await db.insert(workflow).values({ id: newWorkflowId, workspaceId: workspaceId, - name: `${templateData.name} (copy)`, + name: workflowName, description: templateData.description, color: templateData.color, userId: session.user.id, - variables: remappedVariables, createdAt: now, updatedAt: now, lastSynced: now, }) - if (templateState) { - const regeneratedState = regenerateWorkflowStateIds(templateState) - // Strip template variables from the regenerated state (we use remapped ones) - // but include the remapped variables so the save route persists them to Yjs + DB - const { variables: _templateVars, ...stateWithoutTemplateVars } = regeneratedState as any - const stateWithVariables = { - ...stateWithoutTemplateVars, - variables: remappedVariables, - } - - const stateResponse = await fetch(`${getBaseUrl()}/api/workflows/${newWorkflowId}/state`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - cookie: request.headers.get('cookie') || '', - }, - body: JSON.stringify(stateWithVariables), - }) - - if (!stateResponse.ok) { - logger.error(`[${requestId}] Failed to save workflow state for template use`) - await db.delete(workflow).where(eq(workflow.id, newWorkflowId)) - return NextResponse.json( - { error: 'Failed to create workflow from template' }, - { status: 500 } - ) - } + const regeneratedState = regenerateWorkflowStateIds(templateState) + try { + await applyWorkflowState( + newWorkflowId, + createWorkflowSnapshot(regeneratedState), + remappedVariables, + { name: workflowName, description: templateData.description } + ) + } catch (error) { + logger.error(`[${requestId}] Failed to save workflow state for template use`, error) + await db.delete(workflow).where(eq(workflow.id, newWorkflowId)) + return NextResponse.json( + { error: 'Failed to create workflow from template' }, + { status: 500 } + ) } await db diff --git a/apps/tradinggoose/app/api/tools/custom/import/route.test.ts b/apps/tradinggoose/app/api/tools/custom/import/route.test.ts index e2cc94b8a..e2845fc31 100644 --- a/apps/tradinggoose/app/api/tools/custom/import/route.test.ts +++ b/apps/tradinggoose/app/api/tools/custom/import/route.test.ts @@ -47,7 +47,6 @@ describe('Custom tools import route', () => { schema: { type: 'function', function: { - name: 'myTool_imported_1', parameters: { type: 'object', properties: {}, @@ -81,7 +80,6 @@ describe('Custom tools import route', () => { schema: { type: 'function', function: { - name: 'myTool', parameters: { type: 'object', properties: {}, @@ -112,7 +110,6 @@ describe('Custom tools import route', () => { schema: { type: 'function', function: { - name: 'myTool', parameters: { type: 'object', properties: {}, @@ -143,7 +140,6 @@ describe('Custom tools import route', () => { schema: { type: 'function', function: { - name: 'myTool', parameters: { type: 'object', properties: {}, @@ -184,7 +180,6 @@ describe('Custom tools import route', () => { schema: { type: 'function', function: { - name: 'myTool', parameters: { type: 'object', properties: {}, @@ -229,7 +224,6 @@ describe('Custom tools import route', () => { schema: { type: 'function', function: { - name: 'myTool', parameters: { type: 'object', properties: {}, @@ -281,7 +275,6 @@ describe('Custom tools import route', () => { schema: { type: 'function', function: { - name: 'myTool', parameters: { type: 'object', properties: {}, diff --git a/apps/tradinggoose/app/api/tools/custom/import/route.ts b/apps/tradinggoose/app/api/tools/custom/import/route.ts index d5ac85980..f344c1e0d 100644 --- a/apps/tradinggoose/app/api/tools/custom/import/route.ts +++ b/apps/tradinggoose/app/api/tools/custom/import/route.ts @@ -6,6 +6,7 @@ import { importCustomTools } from '@/lib/custom-tools/operations' import { createLogger } from '@/lib/logs/console/logger' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' const logger = createLogger('CustomToolsImportAPI') @@ -59,6 +60,9 @@ export async function POST(request: NextRequest) { }, }) } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } if (error instanceof z.ZodError) { logger.warn(`[${requestId}] Invalid custom tools import data`, { errors: error.errors }) const workspaceError = error.errors.find( diff --git a/apps/tradinggoose/app/api/tools/custom/route.test.ts b/apps/tradinggoose/app/api/tools/custom/route.test.ts index d21b1c2b5..9fdcc31e6 100644 --- a/apps/tradinggoose/app/api/tools/custom/route.test.ts +++ b/apps/tradinggoose/app/api/tools/custom/route.test.ts @@ -6,7 +6,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mockCheckHybridAuth = vi.fn() const mockGetUserEntityPermissions = vi.fn() -const mockUpsertCustomTools = vi.fn() +const mockCreateCustomTools = vi.fn() +const mockSaveCustomTool = vi.fn() +const mockListCustomTools = vi.fn() +const mockReadWorkflowAccessContext = vi.fn() vi.mock('@/lib/auth/hybrid', () => ({ checkHybridAuth: mockCheckHybridAuth, @@ -17,7 +20,13 @@ vi.mock('@/lib/permissions/utils', () => ({ })) vi.mock('@/lib/custom-tools/operations', () => ({ - upsertCustomTools: mockUpsertCustomTools, + createCustomTools: mockCreateCustomTools, + saveCustomTool: mockSaveCustomTool, + listCustomTools: mockListCustomTools, +})) + +vi.mock('@/lib/workflows/utils', () => ({ + readWorkflowAccessContext: mockReadWorkflowAccessContext, })) vi.mock('@tradinggoose/db', () => ({ @@ -45,7 +54,10 @@ describe('Custom Tools API Routes', () => { vi.resetAllMocks() mockCheckHybridAuth.mockResolvedValue({ success: true, userId: 'user-123' }) mockGetUserEntityPermissions.mockResolvedValue('admin') - mockUpsertCustomTools.mockResolvedValue([]) + mockCreateCustomTools.mockResolvedValue([]) + mockSaveCustomTool.mockResolvedValue([]) + mockListCustomTools.mockResolvedValue([]) + mockReadWorkflowAccessContext.mockResolvedValue(null) }) afterEach(() => { @@ -71,7 +83,24 @@ describe('Custom Tools API Routes', () => { const body = await res.json() expect(res.status).toBe(400) - expect(body.error).toBe('workspaceId is required') + expect(body.error).toBe('workspaceId or workflowId is required') + }) + + it('GET should resolve workspace from workflowId', async () => { + mockReadWorkflowAccessContext.mockResolvedValue({ + workflow: { workspaceId: 'ws-1' }, + isOwner: false, + isWorkspaceOwner: false, + workspacePermission: 'read', + }) + + const req = new NextRequest('http://localhost:3000/api/tools/custom?workflowId=workflow-1') + const { GET } = await import('@/app/api/tools/custom/route') + const res = await GET(req) + + expect(res.status).toBe(200) + expect(mockReadWorkflowAccessContext).toHaveBeenCalledWith('workflow-1', 'user-123') + expect(mockListCustomTools).toHaveBeenCalledWith({ workspaceId: 'ws-1' }) }) it('POST should require workspaceId in body', async () => { diff --git a/apps/tradinggoose/app/api/tools/custom/route.ts b/apps/tradinggoose/app/api/tools/custom/route.ts index 7e38db129..264ec7349 100644 --- a/apps/tradinggoose/app/api/tools/custom/route.ts +++ b/apps/tradinggoose/app/api/tools/custom/route.ts @@ -1,15 +1,21 @@ import { db } from '@tradinggoose/db' -import { customTools, workflow } from '@tradinggoose/db/schema' +import { customTools } from '@tradinggoose/db/schema' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkHybridAuth } from '@/lib/auth/hybrid' -import { listCustomTools, upsertCustomTools } from '@/lib/custom-tools/operations' -import { CustomToolUpsertRequestSchema } from '@/lib/custom-tools/schema' +import { createCustomTools, listCustomTools, saveCustomTool } from '@/lib/custom-tools/operations' +import { CustomToolWriteRequestSchema } from '@/lib/custom-tools/schema' import { createLogger } from '@/lib/logs/console/logger' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' -import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' +import { readWorkflowAccessContext } from '@/lib/workflows/utils' +import { SavedEntityRealtimeRequiredError } from '@/lib/yjs/entity-state' +import { SavedEntityPersistenceError } from '@/lib/yjs/server/apply-entity-state' +import { + deleteYjsSessionInSocketServer, + notifyEntityListMemberRemoved, +} from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('CustomToolsAPI') @@ -17,8 +23,8 @@ const logger = createLogger('CustomToolsAPI') export async function GET(request: NextRequest) { const requestId = generateRequestId() const searchParams = request.nextUrl.searchParams - const workspaceId = searchParams.get('workspaceId') - const workflowId = searchParams.get('workflowId') + const queryWorkspaceId = searchParams.get('workspaceId')?.trim() ?? '' + const workflowId = searchParams.get('workflowId')?.trim() ?? '' try { const authResult = await checkHybridAuth(request, { requireWorkflowId: false }) @@ -28,43 +34,41 @@ export async function GET(request: NextRequest) { } const userId = authResult.userId - let resolvedWorkspaceId: string | null = workspaceId - - if (!resolvedWorkspaceId && workflowId) { - const [workflowData] = await db - .select({ workspaceId: workflow.workspaceId }) - .from(workflow) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (!workflowData?.workspaceId) { - logger.warn(`[${requestId}] Workflow not found: ${workflowId}`) + let workspaceId = queryWorkspaceId + if (!workspaceId && workflowId) { + const accessContext = await readWorkflowAccessContext(workflowId, userId) + if (!accessContext) { return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } - - resolvedWorkspaceId = workflowData.workspaceId - } - - if (!resolvedWorkspaceId) { - logger.warn(`[${requestId}] Missing workspaceId for custom tools fetch`) - return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) - } - - // Skip permission check for internal JWT workflow proxy requests - if (!(authResult.authType === 'internal_jwt' && workflowId)) { - const permission = await getUserEntityPermissions(userId, 'workspace', resolvedWorkspaceId) + if ( + !accessContext.isOwner && + !accessContext.isWorkspaceOwner && + !accessContext.workspacePermission + ) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + if (!accessContext.workflow.workspaceId) { + return NextResponse.json({ error: 'Workflow workspace is missing' }, { status: 404 }) + } + workspaceId = accessContext.workflow.workspaceId + } else if (!workspaceId) { + logger.warn(`[${requestId}] Missing workspaceId or workflowId for custom tools fetch`) + return NextResponse.json({ error: 'workspaceId or workflowId is required' }, { status: 400 }) + } else { + const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) if (!permission) { logger.warn( - `[${requestId}] User ${userId} does not have access to workspace ${resolvedWorkspaceId}` + `[${requestId}] User ${userId} does not have access to workspace ${workspaceId}` ) return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } } - const result = await listCustomTools({ workspaceId: resolvedWorkspaceId }) - - return NextResponse.json({ data: result }, { status: 200 }) + return NextResponse.json({ data: await listCustomTools({ workspaceId }) }, { status: 200 }) } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } logger.error(`[${requestId}] Error fetching custom tools:`, error) return NextResponse.json({ error: 'Failed to fetch custom tools' }, { status: 500 }) } @@ -85,7 +89,7 @@ export async function POST(req: NextRequest) { try { // Validate the request body - const { tools, workspaceId } = CustomToolUpsertRequestSchema.parse(body) + const { tools, workspaceId } = CustomToolWriteRequestSchema.parse(body) const permission = await getUserEntityPermissions(authResult.userId, 'workspace', workspaceId) if (!permission) { @@ -102,12 +106,39 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) } - const resultTools = await upsertCustomTools({ - tools, - workspaceId, - userId: authResult.userId, - requestId, - }) + const toolsToCreate = tools.filter((tool) => !tool.id) + const toolsToSave = tools.filter((tool) => tool.id) + if (toolsToCreate.length > 0 && toolsToSave.length > 0) { + return NextResponse.json( + { error: 'Create and save custom tools in separate requests' }, + { status: 400 } + ) + } + if (toolsToSave.length > 1) { + return NextResponse.json( + { error: 'Save one existing custom tool per request' }, + { status: 400 } + ) + } + + const resultTools = + toolsToSave.length === 1 + ? await saveCustomTool({ + tool: { + id: toolsToSave[0].id!, + title: toolsToSave[0].title, + schema: toolsToSave[0].schema, + code: toolsToSave[0].code, + }, + workspaceId, + requestId, + }) + : await createCustomTools({ + tools: toolsToCreate, + workspaceId, + userId: authResult.userId, + requestId, + }) return NextResponse.json({ success: true, data: resultTools }) } catch (validationError) { @@ -128,9 +159,21 @@ export async function POST(req: NextRequest) { { status: 400 } ) } + if (validationError instanceof SavedEntityPersistenceError) { + return NextResponse.json(validationError.responseBody(), { status: validationError.status }) + } + if (validationError instanceof Error && validationError.message.includes('already exists')) { + return NextResponse.json({ error: validationError.message }, { status: 409 }) + } + if (validationError instanceof Error && validationError.message.includes('was not found')) { + return NextResponse.json({ error: validationError.message }, { status: 404 }) + } throw validationError } } catch (error) { + if (error instanceof SavedEntityRealtimeRequiredError) { + return NextResponse.json(error.responseBody(), { status: error.status }) + } logger.error(`[${requestId}] Error updating custom tools`, error) return NextResponse.json({ error: 'Failed to update custom tools' }, { status: 500 }) } @@ -174,20 +217,25 @@ export async function DELETE(request: NextRequest) { return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) } - // Check if the tool exists in this workspace - const existingTool = await db - .select() + const [existingTool] = await db + .select({ id: customTools.id }) .from(customTools) .where(and(eq(customTools.id, toolId), eq(customTools.workspaceId, workspaceId))) .limit(1) - if (existingTool.length === 0) { + if (!existingTool) { logger.warn(`[${requestId}] Tool not found: ${toolId}`) return NextResponse.json({ error: 'Tool not found' }, { status: 404 }) } - await deleteYjsSessionInSocketServer(toolId) - await db.delete(customTools).where(eq(customTools.id, toolId)) + await db + .delete(customTools) + .where(and(eq(customTools.id, toolId), eq(customTools.workspaceId, workspaceId))) + + await Promise.allSettled([ + deleteYjsSessionInSocketServer(toolId), + notifyEntityListMemberRemoved('custom_tool', workspaceId, toolId), + ]) logger.info(`[${requestId}] Deleted tool: ${toolId}`) return NextResponse.json({ success: true }) diff --git a/apps/tradinggoose/app/api/users/me/api-keys/[id]/route.ts b/apps/tradinggoose/app/api/users/me/api-keys/[id]/route.ts index 368b9492f..b865d5d9c 100644 --- a/apps/tradinggoose/app/api/users/me/api-keys/[id]/route.ts +++ b/apps/tradinggoose/app/api/users/me/api-keys/[id]/route.ts @@ -33,7 +33,7 @@ export async function DELETE( // Delete the API key, ensuring it belongs to the current user const result = await db .delete(apiKey) - .where(and(eq(apiKey.id, keyId), eq(apiKey.userId, userId))) + .where(and(eq(apiKey.id, keyId), eq(apiKey.userId, userId), eq(apiKey.type, 'personal'))) .returning({ id: apiKey.id }) if (!result.length) { diff --git a/apps/tradinggoose/app/api/users/me/api-keys/route.ts b/apps/tradinggoose/app/api/users/me/api-keys/route.ts index 96179b231..af04b24ed 100644 --- a/apps/tradinggoose/app/api/users/me/api-keys/route.ts +++ b/apps/tradinggoose/app/api/users/me/api-keys/route.ts @@ -3,7 +3,11 @@ import { apiKey } from '@tradinggoose/db/schema' import { and, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' import { type NextRequest, NextResponse } from 'next/server' -import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth' +import { + createApiKey, + getApiKeyDisplayFormat, + isApiKeyStorageAvailable, +} from '@/lib/api-key/service' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' @@ -32,16 +36,10 @@ export async function GET(request: NextRequest) { .where(and(eq(apiKey.userId, userId), eq(apiKey.type, 'personal'))) .orderBy(apiKey.createdAt) - const maskedKeys = await Promise.all( - keys.map(async (key) => { - const displayFormat = await getApiKeyDisplayFormat(key.key) - return { - ...key, - key: key.key, - displayKey: displayFormat, - } - }) - ) + const maskedKeys = keys.flatMap(({ key, ...apiKey }) => { + const displayKey = getApiKeyDisplayFormat(key) + return displayKey ? [{ ...apiKey, displayKey }] : [] + }) return NextResponse.json({ keys: maskedKeys }) } catch (error) { @@ -86,10 +84,13 @@ export async function POST(request: NextRequest) { ) } - const { key: plainKey, encryptedKey } = await createApiKey(true) + if (!isApiKeyStorageAvailable()) { + return NextResponse.json({ error: 'API key access is not configured' }, { status: 503 }) + } - if (!encryptedKey) { - throw new Error('Failed to encrypt API key for storage') + const { key: plainKey, storedKey } = await createApiKey(true) + if (!storedKey) { + throw new Error('Failed to prepare API key for storage') } const [newKey] = await db @@ -99,7 +100,7 @@ export async function POST(request: NextRequest) { userId, workspaceId: null, name, - key: encryptedKey, + key: storedKey, type: 'personal', createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/tradinggoose/app/api/v1/auth.ts b/apps/tradinggoose/app/api/v1/auth.ts index b9e5b59c2..2f5b6c1f2 100644 --- a/apps/tradinggoose/app/api/v1/auth.ts +++ b/apps/tradinggoose/app/api/v1/auth.ts @@ -1,5 +1,9 @@ import type { NextRequest } from 'next/server' -import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service' +import { + type ApiKeyType, + authenticateApiKeyFromHeader, + updateApiKeyLastUsed, +} from '@/lib/api-key/service' import { createLogger } from '@/lib/logs/console/logger' const logger = createLogger('V1Auth') @@ -8,7 +12,7 @@ export interface AuthResult { authenticated: boolean userId?: string workspaceId?: string - keyType?: 'personal' | 'workspace' + keyType?: ApiKeyType error?: string } diff --git a/apps/tradinggoose/app/api/v1/middleware.ts b/apps/tradinggoose/app/api/v1/middleware.ts index 485b1c44f..dd151eff8 100644 --- a/apps/tradinggoose/app/api/v1/middleware.ts +++ b/apps/tradinggoose/app/api/v1/middleware.ts @@ -1,50 +1,15 @@ import { type NextRequest, NextResponse } from 'next/server' -import { getPersonalEffectiveSubscription } from '@/lib/billing/core/subscription' -import { isBillingEnabledForRuntime } from '@/lib/billing/settings' +import { + checkApiEndpointRateLimit, + createApiAuthFailureRateLimitResult, + type RateLimitResult, +} from '@/lib/api/rate-limit' import { createLogger } from '@/lib/logs/console/logger' -import { ExecutionLimiter } from '@/services/queue/ExecutionLimiter' import { authenticateV1Request } from './auth' const logger = createLogger('V1Middleware') -const rateLimiter = new ExecutionLimiter() -type RateLimitFailureKind = 'auth' | 'dependency' - -async function getDefaultApiEndpointRateLimit(): Promise { - return (await isBillingEnabledForRuntime()) ? 0 : Number.MAX_SAFE_INTEGER -} - -export interface RateLimitResult { - allowed: boolean - remaining: number - resetAt: Date - limit: number - userId?: string - error?: string - failureKind?: RateLimitFailureKind -} - -function createAuthFailureResult(error: string, limit: number): RateLimitResult { - return { - allowed: false, - remaining: 0, - limit, - resetAt: new Date(), - error, - failureKind: 'auth', - } -} - -function createDependencyFailureResult(error: string): RateLimitResult { - return { - allowed: false, - remaining: 0, - limit: 0, - resetAt: new Date(Date.now() + 60000), - error, - failureKind: 'dependency', - } -} +export type { RateLimitResult } from '@/lib/api/rate-limit' export async function checkRateLimit( request: NextRequest, @@ -56,65 +21,16 @@ export async function checkRateLimit( auth = await authenticateV1Request(request) } catch (error) { logger.error('Authentication error during rate limit check', { error }) - const limit = await getDefaultApiEndpointRateLimit().catch(() => 0) - return createAuthFailureResult('Authentication failed', limit) + return createApiAuthFailureRateLimitResult('Authentication failed') } if (!auth.authenticated) { - const limit = await getDefaultApiEndpointRateLimit() - return createAuthFailureResult(auth.error || 'Unauthorized', limit) + return createApiAuthFailureRateLimitResult(auth.error || 'Unauthorized') } const userId = auth.userId! - try { - const billingEnabled = await isBillingEnabledForRuntime() - if (!billingEnabled) { - return { - allowed: true, - remaining: Number.MAX_SAFE_INTEGER, - limit: Number.MAX_SAFE_INTEGER, - resetAt: new Date(Date.now() + 60000), - userId, - } - } - - const subscription = await getPersonalEffectiveSubscription(userId) - - const result = await rateLimiter.checkRateLimitWithSubscription( - userId, - subscription, - 'api-endpoint', - false - ) - - if (!result.allowed) { - logger.warn(`Rate limit exceeded for user ${userId}`, { - endpoint, - remaining: result.remaining, - resetAt: result.resetAt, - }) - } - - const rateLimitStatus = await rateLimiter.getRateLimitStatusWithSubscription( - userId, - subscription, - 'api-endpoint', - false - ) - - return { - ...result, - limit: rateLimitStatus.limit, - userId, - } - } catch (error) { - logger.error('Rate limit check error; failing closed', { error, endpoint, userId }) - return { - ...createDependencyFailureResult('Rate limit service unavailable'), - userId, - } - } + return checkApiEndpointRateLimit(userId, endpoint) } export function createRateLimitResponse(result: RateLimitResult): NextResponse { diff --git a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts index 45217e348..a64185652 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts @@ -3,8 +3,11 @@ import { z } from 'zod' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' import { applyAutoLayout } from '@/lib/workflows/autolayout' -import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers' +import { requireWorkflowRealtimeState } from '@/lib/workflows/db-helpers' import { validateWorkflowPermissions } from '@/lib/workflows/utils' +import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' +import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' +import { createWorkflowRealtimeRequiredResponse } from '@/app/api/workflows/utils' export const dynamic = 'force-dynamic' @@ -51,22 +54,22 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ userId, }) - let currentWorkflowData: { blocks: Record; edges: any[] } | null + const currentWorkflowState = await requireWorkflowRealtimeState(workflowId) + + if (!currentWorkflowState) { + logger.error(`[${requestId}] Could not load workflow ${workflowId} for autolayout`) + return NextResponse.json({ error: 'Could not load workflow data' }, { status: 500 }) + } + + const layoutInput = + layoutOptions.blocks && layoutOptions.edges + ? { blocks: layoutOptions.blocks, edges: layoutOptions.edges } + : { blocks: currentWorkflowState.blocks, edges: currentWorkflowState.edges } if (layoutOptions.blocks && layoutOptions.edges) { logger.info(`[${requestId}] Using provided blocks with live measurements`) - currentWorkflowData = { - blocks: layoutOptions.blocks, - edges: layoutOptions.edges, - } } else { - logger.info(`[${requestId}] Loading blocks from database`) - currentWorkflowData = await loadWorkflowFromNormalizedTables(workflowId) - } - - if (!currentWorkflowData) { - logger.error(`[${requestId}] Could not load workflow ${workflowId} for autolayout`) - return NextResponse.json({ error: 'Could not load workflow data' }, { status: 500 }) + logger.info(`[${requestId}] Loading blocks from current workflow state`) } const autoLayoutOptions = { @@ -79,11 +82,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ alignment: layoutOptions.alignment ?? 'center', } - const layoutResult = applyAutoLayout( - currentWorkflowData.blocks, - currentWorkflowData.edges, - autoLayoutOptions - ) + const layoutResult = applyAutoLayout(layoutInput.blocks, layoutInput.edges, autoLayoutOptions) if (!layoutResult.success || !layoutResult.blocks) { logger.error(`[${requestId}] Auto layout failed:`, { @@ -98,6 +97,17 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ ) } + await applyWorkflowState( + workflowId, + createWorkflowSnapshot({ + direction: currentWorkflowState.direction, + blocks: layoutResult.blocks, + edges: layoutInput.edges, + loops: currentWorkflowState.loops, + parallels: currentWorkflowState.parallels, + }) + ) + const elapsed = Date.now() - startTime const blockCount = Object.keys(layoutResult.blocks).length @@ -112,11 +122,12 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ data: { blockCount, elapsed: `${elapsed}ms`, - layoutedBlocks: layoutResult.blocks, }, }) } catch (error) { const elapsed = Date.now() - startTime + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse if (error instanceof z.ZodError) { logger.warn(`[${requestId}] Invalid autolayout request data`, { errors: error.errors }) diff --git a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts index cf6145158..b5569944c 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.test.ts @@ -37,7 +37,7 @@ describe('Workflow Deploy API Route', () => { vi.doMock('@/lib/workflows/db-helpers', () => ({ deployWorkflow: vi.fn(), - loadWorkflowState: (...args: unknown[]) => mockLoadWorkflowState(...args), + requireWorkflowRealtimeState: (...args: unknown[]) => mockLoadWorkflowState(...args), })) vi.doMock('@/lib/chat/published-deployment', () => ({ @@ -58,6 +58,7 @@ describe('Workflow Deploy API Route', () => { Response.json({ error }, { status }) ), createSuccessResponse: vi.fn((data: unknown) => Response.json(data, { status: 200 })), + createWorkflowRealtimeRequiredResponse: vi.fn(() => null), })) vi.doMock('drizzle-orm', () => ({ diff --git a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts index 851a54a5c..59b99c661 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deploy/route.ts @@ -7,11 +7,15 @@ import { } from '@/lib/chat/published-deployment' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { deployWorkflow, loadWorkflowState } from '@/lib/workflows/db-helpers' +import { deployWorkflow, requireWorkflowRealtimeState } from '@/lib/workflows/db-helpers' import { hasWorkflowChanged, validateWorkflowPermissions } from '@/lib/workflows/utils' import { notifyMonitorsReconcile } from '@/app/api/monitors/reconcile' import { pauseMonitorsMissingDeployedTrigger } from '@/app/api/monitors/shared' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { + createErrorResponse, + createSuccessResponse, + createWorkflowRealtimeRequiredResponse, +} from '@/app/api/workflows/utils' const logger = createLogger('WorkflowDeployAPI') @@ -99,7 +103,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ .limit(1) if (active?.state) { - const currentState = await loadWorkflowState(id, workflowData.lastSynced) + const currentState = await requireWorkflowRealtimeState(id) if (currentState) { needsRedeployment = hasWorkflowChanged(currentState, active.state as any) } @@ -119,6 +123,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ }) } catch (error: any) { logger.error(`[${requestId}] Error fetching deployment info: ${id}`, error) + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse return createErrorResponse(error.message || 'Failed to fetch deployment information', 500) } } @@ -290,6 +296,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ cause: error.cause, fullError: error, }) + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse return createErrorResponse(error.message || 'Failed to deploy workflow', 500) } } diff --git a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts index a9e244307..0cdf8cd22 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.test.ts @@ -6,28 +6,16 @@ import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' describe('Revert To Deployment Version API Route', () => { - const callOrder: string[] = [] - const mockValidateWorkflowPermissions = vi.fn() - const mockSaveWorkflowToNormalizedTables = vi.fn() - const mockTryApplyWorkflowState = vi.fn() + const mockApplyWorkflowState = vi.fn() const mockDbSelectLimit = vi.fn() - const mockDbUpdateWhere = vi.fn() beforeEach(() => { vi.resetModules() vi.clearAllMocks() - callOrder.length = 0 mockValidateWorkflowPermissions.mockResolvedValue({ error: null }) - mockSaveWorkflowToNormalizedTables.mockImplementation(async () => { - callOrder.push('save') - return { success: true } - }) - mockTryApplyWorkflowState.mockImplementation(async () => { - callOrder.push('apply') - return { success: true } - }) + mockApplyWorkflowState.mockResolvedValue(undefined) mockDbSelectLimit.mockResolvedValue([ { state: { @@ -53,10 +41,6 @@ describe('Revert To Deployment Version API Route', () => { }, }, ]) - mockDbUpdateWhere.mockImplementation(async () => { - callOrder.push('db-update') - }) - vi.doMock('drizzle-orm', () => ({ and: vi.fn((...conditions) => conditions), eq: vi.fn((field, value) => ({ field, value })), @@ -71,14 +55,6 @@ describe('Revert To Deployment Version API Route', () => { }), }), }), - update: vi.fn().mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: mockDbUpdateWhere, - }), - }), - }, - workflow: { - id: 'workflow.id', }, workflowDeploymentVersion: { state: 'state', @@ -107,11 +83,12 @@ describe('Revert To Deployment Version API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ - saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables, + ensureUniqueBlockIds: vi.fn(async (_workflowId: string, state: any) => state), + ensureUniqueEdgeIds: vi.fn(async (_workflowId: string, state: any) => state), })) vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ - tryApplyWorkflowState: mockTryApplyWorkflowState, + applyWorkflowState: mockApplyWorkflowState, })) vi.doMock('@/lib/yjs/workflow-session', () => ({ @@ -121,14 +98,13 @@ describe('Revert To Deployment Version API Route', () => { loops: partial.loops ?? {}, parallels: partial.parallels ?? {}, lastSaved: partial.lastSaved, - isDeployed: partial.isDeployed, - deployedAt: partial.deployedAt, })), })) vi.doMock('@/app/api/workflows/utils', () => ({ createErrorResponse: vi.fn((error, status) => Response.json({ error }, { status })), createSuccessResponse: vi.fn((data) => Response.json({ data }, { status: 200 })), + createWorkflowRealtimeRequiredResponse: vi.fn(() => null), })) vi.doMock('@/app/api/monitors/reconcile', () => ({ @@ -144,7 +120,7 @@ describe('Revert To Deployment Version API Route', () => { vi.clearAllMocks() }) - it('publishes the reverted Yjs state only after the durable writes complete', async () => { + it('applies the reverted deployment state through the workflow state helper', async () => { const { POST } = await import('@/app/api/workflows/[id]/deployments/[version]/revert/route') const request = new NextRequest( 'http://localhost:3000/api/workflows/workflow-1/deployments/active/revert' @@ -155,8 +131,7 @@ describe('Revert To Deployment Version API Route', () => { }) expect(response.status).toBe(200) - expect(callOrder).toEqual(['save', 'db-update', 'apply']) - expect(mockTryApplyWorkflowState).toHaveBeenCalledWith( + expect(mockApplyWorkflowState).toHaveBeenCalledWith( 'workflow-1', expect.objectContaining({ blocks: expect.any(Object), @@ -173,8 +148,8 @@ describe('Revert To Deployment Version API Route', () => { ) }) - it('does not publish the reverted Yjs state when the workflow row update fails', async () => { - mockDbUpdateWhere.mockRejectedValueOnce(new Error('database unavailable')) + it('reports workflow state apply failures', async () => { + mockApplyWorkflowState.mockRejectedValueOnce(new Error('database unavailable')) const { POST } = await import('@/app/api/workflows/[id]/deployments/[version]/revert/route') const request = new NextRequest( @@ -186,7 +161,37 @@ describe('Revert To Deployment Version API Route', () => { }) expect(response.status).toBe(500) - expect(callOrder).toEqual(['save']) - expect(mockTryApplyWorkflowState).not.toHaveBeenCalled() + expect(mockApplyWorkflowState).toHaveBeenCalledOnce() + }) + + it('preserves current variables when the deployment snapshot omits variables', async () => { + mockDbSelectLimit.mockResolvedValueOnce([ + { + state: { + blocks: { + 'block-1': { + id: 'block-1', + type: 'script', + subBlocks: {}, + }, + }, + edges: [], + loops: {}, + parallels: {}, + }, + }, + ]) + + const { POST } = await import('@/app/api/workflows/[id]/deployments/[version]/revert/route') + const request = new NextRequest( + 'http://localhost:3000/api/workflows/workflow-1/deployments/active/revert' + ) + + const response = await POST(request, { + params: Promise.resolve({ id: 'workflow-1', version: 'active' }), + }) + + expect(response.status).toBe(200) + expect(mockApplyWorkflowState).toHaveBeenCalledWith('workflow-1', expect.any(Object), undefined) }) }) diff --git a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts index b249490aa..888cfeaa3 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/deployments/[version]/revert/route.ts @@ -1,15 +1,19 @@ -import { db, workflow, workflowDeploymentVersion } from '@tradinggoose/db' +import { db, workflowDeploymentVersion } from '@tradinggoose/db' import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' +import { ensureUniqueBlockIds, ensureUniqueEdgeIds } from '@/lib/workflows/db-helpers' import { validateWorkflowPermissions } from '@/lib/workflows/utils' -import { tryApplyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' +import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' import { notifyMonitorsReconcile } from '@/app/api/monitors/reconcile' import { pauseMonitorsMissingDeployedTrigger } from '@/app/api/monitors/shared' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { + createErrorResponse, + createSuccessResponse, + createWorkflowRealtimeRequiredResponse, +} from '@/app/api/workflows/utils' const logger = createLogger('RevertToDeploymentVersionAPI') @@ -71,52 +75,32 @@ export async function POST( } const now = new Date() - const revertVariables = deployedState.variables || undefined - - const saveResult = await saveWorkflowToNormalizedTables(id, { + const revertVariables = + deployedState.variables && + typeof deployedState.variables === 'object' && + !Array.isArray(deployedState.variables) + ? deployedState.variables + : undefined + + const revertedState = { blocks: deployedState.blocks, edges: deployedState.edges, loops: deployedState.loops || {}, parallels: deployedState.parallels || {}, lastSaved: Date.now(), - isDeployed: true, - deployedAt: new Date(), - }) - - if (!saveResult.success) { - return createErrorResponse(saveResult.error || 'Failed to save deployed state', 500) } - const persistedRevertedState = saveResult.normalizedState ?? { - blocks: deployedState.blocks, - edges: deployedState.edges, - loops: deployedState.loops || {}, - parallels: deployedState.parallels || {}, - lastSaved: Date.now(), - isDeployed: true, - deployedAt: new Date(), - } + const stateWithUniqueBlockIds = await ensureUniqueBlockIds(id, revertedState) + const persistedRevertedState = await ensureUniqueEdgeIds(id, stateWithUniqueBlockIds) const revertSnapshot = createWorkflowSnapshot({ blocks: persistedRevertedState.blocks, edges: persistedRevertedState.edges, loops: persistedRevertedState.loops, parallels: persistedRevertedState.parallels, lastSaved: now.toISOString(), - isDeployed: true, - deployedAt: now.toISOString(), }) - await db - .update(workflow) - .set({ - lastSynced: now, - updatedAt: now, - ...(revertVariables ? { variables: revertVariables } : {}), - }) - .where(eq(workflow.id, id)) - - // Publish the reverted state to Yjs only after the durable writes succeed. - await tryApplyWorkflowState(id, revertSnapshot, revertVariables) + await applyWorkflowState(id, revertSnapshot, revertVariables) await pauseMonitorsMissingDeployedTrigger(id) await notifyMonitorsReconcile({ requestId, logger }) @@ -127,6 +111,8 @@ export async function POST( }) } catch (error: any) { logger.error('Error reverting to deployment version', error) + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse return createErrorResponse(error.message || 'Failed to revert', 500) } } diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts index 1c7a3cea0..9f79c7af1 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.test.ts @@ -6,10 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' describe('Workflow Duplicate API Route', () => { let loadWorkflowStateMock: ReturnType - let remapVariableIdsMock: ReturnType let regenerateWorkflowStateIdsMock: ReturnType - let saveWorkflowToNormalizedTablesMock: ReturnType - let tryApplyWorkflowStateMock: ReturnType + let applyWorkflowStateMock: ReturnType let insertValuesMock: ReturnType let deleteWhereMock: ReturnType @@ -44,21 +42,8 @@ describe('Workflow Duplicate API Route', () => { vi.clearAllMocks() loadWorkflowStateMock = vi.fn() - remapVariableIdsMock = vi.fn((variables, newWorkflowId: string) => - Object.fromEntries( - Object.values(variables as Record).map((variable, index) => [ - `remapped-${index + 1}`, - { - ...variable, - id: `remapped-${index + 1}`, - workflowId: newWorkflowId, - }, - ]) - ) - ) regenerateWorkflowStateIdsMock = vi.fn((state) => JSON.parse(JSON.stringify(state))) - saveWorkflowToNormalizedTablesMock = vi.fn().mockResolvedValue({ success: true }) - tryApplyWorkflowStateMock = vi.fn().mockResolvedValue({ success: true }) + applyWorkflowStateMock = vi.fn().mockResolvedValue(undefined) insertValuesMock = vi.fn().mockResolvedValue(undefined) deleteWhereMock = vi.fn().mockResolvedValue(undefined) @@ -122,18 +107,14 @@ describe('Workflow Duplicate API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ - loadWorkflowState: loadWorkflowStateMock, - remapVariableIds: remapVariableIdsMock, + isWorkflowRealtimeRequiredError: vi.fn(() => false), + requireWorkflowRealtimeState: loadWorkflowStateMock, regenerateWorkflowStateIds: regenerateWorkflowStateIdsMock, - saveWorkflowToNormalizedTables: saveWorkflowToNormalizedTablesMock, + WORKFLOW_REALTIME_REQUIRED_CODE: 'WORKFLOW_REALTIME_REQUIRED', })) vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ - tryApplyWorkflowState: tryApplyWorkflowStateMock, - })) - - vi.doMock('@/lib/yjs/workflow-session', () => ({ - createWorkflowSnapshot: vi.fn((snapshot: Record) => snapshot), + applyWorkflowState: applyWorkflowStateMock, })) }) @@ -141,7 +122,7 @@ describe('Workflow Duplicate API Route', () => { vi.clearAllMocks() }) - it('prefers the live Yjs source graph and variables when duplicating a workflow', async () => { + it('uses the saved source graph and variables when duplicating a workflow', async () => { loadWorkflowStateMock.mockResolvedValue({ blocks: { 'live-block': { @@ -167,7 +148,6 @@ describe('Workflow Duplicate API Route', () => { }, }, lastSaved: Date.now(), - source: 'yjs', }) const { POST } = await import('@/app/api/workflows/[id]/duplicate/route') @@ -180,42 +160,33 @@ describe('Workflow Duplicate API Route', () => { expect(response.status).toBe(201) expect(insertValuesMock).toHaveBeenCalledOnce() - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() - expect(tryApplyWorkflowStateMock).toHaveBeenCalledOnce() + expect(applyWorkflowStateMock).toHaveBeenCalledOnce() const insertedWorkflow = insertValuesMock.mock.calls[0][0] - const appliedWorkflowId = tryApplyWorkflowStateMock.mock.calls[0][0] - const appliedSnapshot = tryApplyWorkflowStateMock.mock.calls[0][1] - const appliedVariables = tryApplyWorkflowStateMock.mock.calls[0][2] - const savedState = saveWorkflowToNormalizedTablesMock.mock.calls[0][1] + const persistedWorkflowId = applyWorkflowStateMock.mock.calls[0][0] + const persistedState = applyWorkflowStateMock.mock.calls[0][1] + const persistedVariables = applyWorkflowStateMock.mock.calls[0][2] - expect(insertedWorkflow.id).toBe(appliedWorkflowId) - expect(appliedSnapshot.blocks).toEqual( - expect.objectContaining({ - [Object.keys(appliedSnapshot.blocks)[0]]: expect.objectContaining({ - name: 'Live Agent', - }), - }) - ) - expect(savedState.blocks).toEqual( + expect(insertedWorkflow.id).toBe(persistedWorkflowId) + expect(persistedState.blocks).toEqual( expect.objectContaining({ - [Object.keys(savedState.blocks)[0]]: expect.objectContaining({ + [Object.keys(persistedState.blocks)[0]]: expect.objectContaining({ name: 'Live Agent', }), }) ) - expect(Object.keys(appliedVariables)).toHaveLength(1) - expect(Object.values(appliedVariables)).toEqual([ + expect(Object.keys(persistedVariables)).toHaveLength(1) + expect(Object.values(persistedVariables)).toEqual([ expect.objectContaining({ name: 'liveVar', value: 'live value', - workflowId: appliedWorkflowId, + workflowId: persistedWorkflowId, }), ]) - expect((Object.values(appliedVariables)[0] as { id: string }).id).not.toBe('live-var') + expect((Object.values(persistedVariables)[0] as { id: string }).id).not.toBe('live-var') }) - it('keeps the duplicate when canonical persistence succeeds but Yjs sync fails', async () => { + it('rolls back the duplicate when Yjs state materialization fails', async () => { loadWorkflowStateMock.mockResolvedValue({ blocks: {}, edges: [], @@ -223,12 +194,8 @@ describe('Workflow Duplicate API Route', () => { parallels: {}, variables: {}, lastSaved: Date.now(), - source: 'normalized', - }) - tryApplyWorkflowStateMock.mockResolvedValueOnce({ - success: false, - error: new Error('socket bridge unavailable'), }) + applyWorkflowStateMock.mockRejectedValueOnce(new Error('realtime unavailable')) const { POST } = await import('@/app/api/workflows/[id]/duplicate/route') const response = await POST( @@ -238,10 +205,9 @@ describe('Workflow Duplicate API Route', () => { } ) - expect(response.status).toBe(201) - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() - expect(tryApplyWorkflowStateMock).toHaveBeenCalledOnce() - expect(deleteWhereMock).not.toHaveBeenCalled() + expect(response.status).toBe(500) + expect(applyWorkflowStateMock).toHaveBeenCalledOnce() + expect(deleteWhereMock).toHaveBeenCalledOnce() }) it('rejects duplication without workspace scope', async () => { diff --git a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts index b61846eb3..5194a0903 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts @@ -8,15 +8,15 @@ import { getStableVibrantColor } from '@/lib/colors' import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' -import { normalizeVariables } from '@/lib/workflows/variable-utils' import { - loadWorkflowState, regenerateWorkflowStateIds, - remapVariableIds, - saveWorkflowToNormalizedTables, + requireWorkflowRealtimeState, } from '@/lib/workflows/db-helpers' -import { tryApplyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' +import { remapVariableIds } from '@/lib/workflows/import-export' +import { normalizeVariables } from '@/lib/workflows/variable-utils' +import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' +import { createWorkflowRealtimeRequiredResponse } from '@/app/api/workflows/utils' import type { Variable } from '@/stores/variables/types' import type { WorkflowState } from '@/stores/workflows/workflow/types' @@ -29,38 +29,25 @@ const DuplicateRequestSchema = z.object({ folderId: z.string().nullable().optional(), }) -async function loadSourceWorkflowArtifacts( - sourceWorkflowId: string, - sourceVariables: unknown -): Promise<{ +async function loadSourceWorkflowRealtimeArtifacts(sourceWorkflowId: string): Promise<{ workflowState: WorkflowState variables: Record - source: 'yjs' | 'normalized' }> { - const stateWithSource = await loadWorkflowState(sourceWorkflowId) - if (!stateWithSource) { + const editableState = await requireWorkflowRealtimeState(sourceWorkflowId) + if (!editableState) { throw new Error('Failed to load source workflow state') } - // When the state came from Yjs the variables are already embedded in the - // snapshot. For the normalized-table path, prefer the caller-supplied - // source variables (from the workflow row). - const variables = - stateWithSource.source === 'yjs' - ? normalizeVariables(stateWithSource.variables) - : normalizeVariables(sourceVariables) - return { workflowState: { - blocks: stateWithSource.blocks, - edges: stateWithSource.edges, - loops: stateWithSource.loops, - parallels: stateWithSource.parallels, - lastSaved: stateWithSource.lastSaved ?? Date.now(), - isDeployed: false, + ...(editableState.direction !== undefined ? { direction: editableState.direction } : {}), + blocks: editableState.blocks, + edges: editableState.edges, + loops: editableState.loops, + parallels: editableState.parallels, + lastSaved: editableState.lastSaved ?? Date.now(), }, - variables, - source: stateWithSource.source, + variables: normalizeVariables(editableState.variables), } } @@ -117,7 +104,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: ) } - const sourceArtifacts = await loadSourceWorkflowArtifacts(sourceWorkflowId, source.variables) + const sourceArtifacts = await loadSourceWorkflowRealtimeArtifacts(sourceWorkflowId) const newWorkflowId = crypto.randomUUID() const now = new Date() @@ -125,6 +112,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: const duplicatedWorkflowState = regenerateWorkflowStateIds(sourceArtifacts.workflowState) const duplicatedVariables = remapVariableIds(sourceArtifacts.variables, newWorkflowId) + const resolvedDescription = description || source.description await db.insert(workflow).values({ id: newWorkflowId, @@ -132,7 +120,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: workspaceId, folderId: folderId || null, name, - description: description || source.description, + description: resolvedDescription, color: resolvedColor, lastSynced: now, createdAt: now, @@ -140,58 +128,29 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: isDeployed: false, collaborators: [], runCount: 0, - variables: duplicatedVariables, isPublished: false, marketplaceData: null, }) try { - const lastSaved = now.toISOString() - - // Persist canonical workflow state before best-effort Yjs sync so the duplicate - // survives bridge outages and never depends on socket-server availability. - const saveResult = await saveWorkflowToNormalizedTables(newWorkflowId, duplicatedWorkflowState) - if (!saveResult.success) { - throw new Error(saveResult.error || 'Failed to save duplicated workflow state') - } - - const persistedDuplicatedState = saveResult.normalizedState ?? duplicatedWorkflowState - const duplicatedSnapshot = createWorkflowSnapshot({ - blocks: persistedDuplicatedState.blocks, - edges: persistedDuplicatedState.edges, - loops: persistedDuplicatedState.loops, - parallels: persistedDuplicatedState.parallels, - lastSaved, - isDeployed: false, - }) - - const yjsApplyResult = await tryApplyWorkflowState( + await applyWorkflowState( newWorkflowId, - duplicatedSnapshot, + createWorkflowSnapshot(duplicatedWorkflowState), duplicatedVariables, - name + { name, description: resolvedDescription, folderId: folderId || null } ) - if (!yjsApplyResult.success) { - logger.warn( - `[${requestId}] Duplicated workflow ${newWorkflowId} without Yjs sync; canonical state was persisted`, - { sourceWorkflowId, newWorkflowId, error: yjsApplyResult.error } - ) - } - } catch (duplicationError) { + } catch (error) { await db.delete(workflow).where(eq(workflow.id, newWorkflowId)) - throw duplicationError + throw error } - logger.info( - `[${requestId}] Duplicated workflow state using ${sourceArtifacts.source} source`, - { - sourceWorkflowId, - newWorkflowId, - blocksCount: Object.keys(duplicatedWorkflowState.blocks || {}).length, - edgesCount: duplicatedWorkflowState.edges?.length || 0, - variablesCount: Object.keys(duplicatedVariables).length, - } - ) + logger.info(`[${requestId}] Duplicated editable workflow state from Yjs`, { + sourceWorkflowId, + newWorkflowId, + blocksCount: Object.keys(duplicatedWorkflowState.blocks || {}).length, + edgesCount: duplicatedWorkflowState.edges?.length || 0, + variablesCount: Object.keys(duplicatedVariables).length, + }) const elapsed = Date.now() - startTime logger.info( @@ -215,6 +174,9 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: { status: 201 } ) } catch (error) { + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse + if (error instanceof Error) { if (error.message === 'Source workflow not found') { logger.warn(`[${requestId}] Source workflow ${sourceWorkflowId} not found`) diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts index bb5f160f4..c5c8270c1 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts @@ -18,8 +18,9 @@ describe('Workflow By ID API Route', () => { const mockReadWorkflowById = vi.fn() const mockReadWorkflowAccessContext = vi.fn() - const mockDeleteYjsSessionInSocketServer = vi.fn() const mockLoadWorkflowState = vi.fn() + const mockApplyWorkflowMetadata = vi.fn() + const mockDeleteYjsSession = vi.fn() beforeEach(() => { vi.resetModules() @@ -33,7 +34,9 @@ describe('Workflow By ID API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ - loadWorkflowState: mockLoadWorkflowState, + WORKFLOW_REALTIME_REQUIRED_CODE: 'WORKFLOW_REALTIME_REQUIRED', + isWorkflowRealtimeRequiredError: vi.fn(() => false), + requireWorkflowRealtimeState: mockLoadWorkflowState, })) vi.doMock('@tradinggoose/db', () => ({ @@ -65,13 +68,24 @@ describe('Workflow By ID API Route', () => { mockReadWorkflowById.mockReset() mockReadWorkflowAccessContext.mockReset() - mockDeleteYjsSessionInSocketServer.mockReset() mockLoadWorkflowState.mockReset() - mockDeleteYjsSessionInSocketServer.mockResolvedValue(undefined) + mockApplyWorkflowMetadata.mockReset() + mockDeleteYjsSession.mockReset() mockLoadWorkflowState.mockResolvedValue(null) + mockApplyWorkflowMetadata.mockResolvedValue({ + id: 'workflow-123', + name: 'Updated Workflow', + description: 'Updated description', + folderId: 'folder-1', + workspaceId: null, + }) + mockDeleteYjsSession.mockResolvedValue(undefined) + vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ + applyWorkflowMetadata: mockApplyWorkflowMetadata, + })) vi.doMock('@/lib/yjs/server/snapshot-bridge', () => ({ - deleteYjsSessionInSocketServer: mockDeleteYjsSessionInSocketServer, + deleteYjsSessionInSocketServer: mockDeleteYjsSession, })) vi.doMock('@/lib/workflows/utils', () => ({ @@ -84,6 +98,13 @@ describe('Workflow By ID API Route', () => { vi.clearAllMocks() }) + function expectWorkflowRenameApplied() { + expect(mockLoadWorkflowState).not.toHaveBeenCalled() + expect(mockApplyWorkflowMetadata).toHaveBeenCalledWith('workflow-123', { + name: 'Updated Workflow', + }) + } + describe('GET /api/workflows/[id]', () => { it('should return 401 when user is not authenticated', async () => { vi.doMock('@/lib/auth', () => ({ @@ -141,7 +162,6 @@ describe('Workflow By ID API Route', () => { edges: [], loops: {}, parallels: {}, - source: 'normalized', } vi.doMock('@/lib/auth', () => ({ @@ -194,7 +214,6 @@ describe('Workflow By ID API Route', () => { edges: [], loops: {}, parallels: {}, - source: 'normalized', } vi.doMock('@/lib/auth', () => ({ @@ -268,7 +287,7 @@ describe('Workflow By ID API Route', () => { expect(data.error).toBe('Access denied') }) - it('should return Yjs-backed workflow state when the authoritative loader has it', async () => { + it('should return current workflow state when the loader has it', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', @@ -281,7 +300,6 @@ describe('Workflow By ID API Route', () => { edges: [{ id: 'edge-1', source: 'block-1', target: 'block-2' }], loops: {}, parallels: {}, - source: 'yjs', } vi.doMock('@/lib/auth', () => ({ @@ -313,7 +331,7 @@ describe('Workflow By ID API Route', () => { expect(data.data.state.edges).toEqual(mockWorkflowState.edges) }) - it('should return an empty state when no normalized data exists yet', async () => { + it('should return 409 when current workflow state is missing', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', @@ -342,17 +360,14 @@ describe('Workflow By ID API Route', () => { const { GET } = await import('@/app/api/workflows/[id]/route') const response = await GET(req, { params }) - expect(response.status).toBe(200) + expect(response.status).toBe(409) const data = await response.json() - expect(data.data.state.blocks).toEqual({}) - expect(data.data.state.edges).toEqual([]) - expect(data.data.state.loops).toEqual({}) - expect(data.data.state.parallels).toEqual({}) + expect(data.error).toBe('Workflow state is missing') }) }) describe('DELETE /api/workflows/[id]', () => { - it('should delete the socket/Yjs session after deleting the workflow row', async () => { + it('should delete the workflow row before non-blocking Yjs cleanup', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', @@ -360,6 +375,10 @@ describe('Workflow By ID API Route', () => { workspaceId: null, } const events: string[] = [] + mockDeleteYjsSession.mockImplementation(async () => { + events.push('yjs-delete') + throw new Error('socket offline') + }) vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ @@ -375,10 +394,6 @@ describe('Workflow By ID API Route', () => { isOwner: true, isWorkspaceOwner: false, }) - mockDeleteYjsSessionInSocketServer.mockImplementationOnce(async () => { - events.push('socket-delete') - }) - vi.doMock('@tradinggoose/db', () => ({ db: { delete: vi.fn().mockReturnValue({ @@ -402,11 +417,10 @@ describe('Workflow By ID API Route', () => { expect(response.status).toBe(200) const data = await response.json() expect(data.success).toBe(true) - expect(mockDeleteYjsSessionInSocketServer).toHaveBeenCalledWith('workflow-123') - expect(events).toEqual(['db-delete', 'socket-delete']) + expect(events).toEqual(['db-delete', 'yjs-delete']) }) - it('should not clean up the Yjs session if workflow row deletion fails', async () => { + it('should return 500 if workflow row deletion fails before session cleanup', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', @@ -450,8 +464,8 @@ describe('Workflow By ID API Route', () => { expect(response.status).toBe(500) const data = await response.json() expect(data.error).toBe('Internal server error') + expect(mockDeleteYjsSession).not.toHaveBeenCalled() expect(deleteWhereMock).toHaveBeenCalledOnce() - expect(mockDeleteYjsSessionInSocketServer).not.toHaveBeenCalled() }) it('should allow admin to delete workspace workflow', async () => { @@ -499,14 +513,53 @@ describe('Workflow By ID API Route', () => { expect(data.success).toBe(true) }) - it('should continue deleting the workflow row when socket/Yjs cleanup fails', async () => { + it('should deny deletion for non-admin users', async () => { + const mockWorkflow = { + id: 'workflow-123', + userId: 'other-user', + name: 'Test Workflow', + workspaceId: 'workspace-456', + } + + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-123' }, + }), + })) + + mockReadWorkflowById.mockResolvedValueOnce(mockWorkflow) + mockReadWorkflowAccessContext.mockResolvedValueOnce({ + workflow: mockWorkflow, + workspaceOwnerId: 'workspace-456', + workspacePermission: null, + isOwner: false, + isWorkspaceOwner: false, + }) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + method: 'DELETE', + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const { DELETE } = await import('@/app/api/workflows/[id]/route') + const response = await DELETE(req, { params }) + + expect(response.status).toBe(403) + const data = await response.json() + expect(data.error).toBe('Access denied') + }) + }) + + describe('PUT /api/workflows/[id]', () => { + it('should allow owner to update workflow', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', name: 'Test Workflow', workspaceId: null, } - const deleteWhereMock = vi.fn().mockResolvedValue([{ id: 'workflow-123' }]) + + const updateData = { name: 'Updated Workflow' } vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ @@ -522,38 +575,23 @@ describe('Workflow By ID API Route', () => { isOwner: true, isWorkspaceOwner: false, }) - mockDeleteYjsSessionInSocketServer.mockRejectedValueOnce(new Error('socket offline')) - - vi.doMock('@tradinggoose/db', () => ({ - db: { - delete: vi.fn().mockReturnValue({ - where: deleteWhereMock, - }), - }, - workflow: {}, - })) const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'DELETE', + method: 'PUT', + body: JSON.stringify(updateData), }) const params = Promise.resolve({ id: 'workflow-123' }) - const { DELETE } = await import('@/app/api/workflows/[id]/route') - const response = await DELETE(req, { params }) + const { PUT } = await import('@/app/api/workflows/[id]/route') + const response = await PUT(req, { params }) expect(response.status).toBe(200) const data = await response.json() - expect(data.success).toBe(true) - expect(deleteWhereMock).toHaveBeenCalledOnce() - expect(mockLogger.warn).toHaveBeenCalledWith( - expect.stringContaining('Failed to delete socket/Yjs session for workflow workflow-123'), - expect.objectContaining({ - workflowId: 'workflow-123', - }) - ) + expect(data.workflow.name).toBe('Updated Workflow') + expectWorkflowRenameApplied() }) - it('should deny deletion for non-admin users', async () => { + it('should allow users with write permission to update workflow', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'other-user', @@ -561,6 +599,8 @@ describe('Workflow By ID API Route', () => { workspaceId: 'workspace-456', } + const updateData = { name: 'Updated Workflow' } + vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ user: { id: 'user-123' }, @@ -571,36 +611,42 @@ describe('Workflow By ID API Route', () => { mockReadWorkflowAccessContext.mockResolvedValueOnce({ workflow: mockWorkflow, workspaceOwnerId: 'workspace-456', - workspacePermission: null, + workspacePermission: 'write', isOwner: false, isWorkspaceOwner: false, }) const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { - method: 'DELETE', + method: 'PUT', + body: JSON.stringify(updateData), }) const params = Promise.resolve({ id: 'workflow-123' }) - const { DELETE } = await import('@/app/api/workflows/[id]/route') - const response = await DELETE(req, { params }) + const { PUT } = await import('@/app/api/workflows/[id]/route') + const response = await PUT(req, { params }) - expect(response.status).toBe(403) + expect(response.status).toBe(200) const data = await response.json() - expect(data.error).toBe('Access denied') + expect(data.workflow.name).toBe('Updated Workflow') + expectWorkflowRenameApplied() }) - }) - describe('PUT /api/workflows/[id]', () => { - it('should allow owner to update workflow', async () => { + it('updates workflow metadata through the Yjs session without loading workflow state', async () => { const mockWorkflow = { id: 'workflow-123', userId: 'user-123', name: 'Test Workflow', + description: 'Old description', + folderId: null, workspaceId: null, } - const updateData = { name: 'Updated Workflow' } - const updatedWorkflow = { ...mockWorkflow, ...updateData, updatedAt: new Date() } + const updateData = { description: 'New description', folderId: 'folder-1' } + mockApplyWorkflowMetadata.mockResolvedValueOnce({ + ...mockWorkflow, + ...updateData, + updatedAt: new Date(), + }) vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ @@ -617,19 +663,6 @@ describe('Workflow By ID API Route', () => { isWorkspaceOwner: false, }) - vi.doMock('@tradinggoose/db', () => ({ - db: { - update: vi.fn().mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([updatedWorkflow]), - }), - }), - }), - }, - workflow: {}, - })) - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'PUT', body: JSON.stringify(updateData), @@ -641,19 +674,31 @@ describe('Workflow By ID API Route', () => { expect(response.status).toBe(200) const data = await response.json() - expect(data.workflow.name).toBe('Updated Workflow') + expect(data.workflow.description).toBe('New description') + expect(data.workflow.folderId).toBe('folder-1') + expect(mockLoadWorkflowState).not.toHaveBeenCalled() + expect(mockApplyWorkflowMetadata).toHaveBeenCalledWith('workflow-123', updateData) }) - it('should allow users with write permission to update workflow', async () => { + it('updates workflow name, description, and folder in one Yjs metadata patch', async () => { const mockWorkflow = { id: 'workflow-123', - userId: 'other-user', + userId: 'user-123', name: 'Test Workflow', - workspaceId: 'workspace-456', + description: 'Old description', + folderId: null, + workspaceId: null, } - - const updateData = { name: 'Updated Workflow' } - const updatedWorkflow = { ...mockWorkflow, ...updateData, updatedAt: new Date() } + const updateData = { + name: 'Updated Workflow', + description: 'New description', + folderId: 'folder-1', + } + mockApplyWorkflowMetadata.mockResolvedValueOnce({ + ...mockWorkflow, + ...updateData, + updatedAt: new Date(), + }) vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ @@ -664,25 +709,12 @@ describe('Workflow By ID API Route', () => { mockReadWorkflowById.mockResolvedValueOnce(mockWorkflow) mockReadWorkflowAccessContext.mockResolvedValueOnce({ workflow: mockWorkflow, - workspaceOwnerId: 'workspace-456', - workspacePermission: 'write', - isOwner: false, + workspaceOwnerId: null, + workspacePermission: null, + isOwner: true, isWorkspaceOwner: false, }) - vi.doMock('@tradinggoose/db', () => ({ - db: { - update: vi.fn().mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([updatedWorkflow]), - }), - }), - }), - }, - workflow: {}, - })) - const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'PUT', body: JSON.stringify(updateData), @@ -695,6 +727,10 @@ describe('Workflow By ID API Route', () => { expect(response.status).toBe(200) const data = await response.json() expect(data.workflow.name).toBe('Updated Workflow') + expect(data.workflow.description).toBe('New description') + expect(data.workflow.folderId).toBe('folder-1') + expect(mockLoadWorkflowState).not.toHaveBeenCalled() + expect(mockApplyWorkflowMetadata).toHaveBeenCalledWith('workflow-123', updateData) }) it('should deny update for users with only read permission', async () => { @@ -759,8 +795,7 @@ describe('Workflow By ID API Route', () => { isWorkspaceOwner: false, }) - // Invalid data - empty name - const invalidData = { name: '' } + const invalidData = { name: ' ' } const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { method: 'PUT', @@ -774,6 +809,7 @@ describe('Workflow By ID API Route', () => { expect(response.status).toBe(400) const data = await response.json() expect(data.error).toBe('Invalid request data') + expect(mockApplyWorkflowMetadata).not.toHaveBeenCalled() }) it('should reject generated workflow color updates', async () => { diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.ts b/apps/tradinggoose/app/api/workflows/[id]/route.ts index cc0088b93..17a501a9e 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.ts @@ -6,19 +6,21 @@ import { z } from 'zod' import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service' import { getSession } from '@/lib/auth' import { verifyInternalTokenDetailed } from '@/lib/auth/internal' +import { hydrateListingUI } from '@/lib/listing/hydrate-ui' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { hydrateListingUI } from '@/lib/listing/hydrate-ui' -import { loadWorkflowState } from '@/lib/workflows/db-helpers' +import { requireWorkflowRealtimeState } from '@/lib/workflows/db-helpers' import { readWorkflowAccessContext, readWorkflowById } from '@/lib/workflows/utils' +import { applyWorkflowMetadata } from '@/lib/yjs/server/apply-workflow-state' import { deleteYjsSessionInSocketServer } from '@/lib/yjs/server/snapshot-bridge' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' +import { createWorkflowRealtimeRequiredResponse } from '@/app/api/workflows/utils' const logger = createLogger('WorkflowByIdAPI') const UpdateWorkflowSchema = z .object({ - name: z.string().min(1, 'Name is required').optional(), + name: z.string().trim().min(1, 'Name is required').optional(), description: z.string().optional(), folderId: z.string().nullable().optional(), }) @@ -27,7 +29,7 @@ const UpdateWorkflowSchema = z /** * GET /api/workflows/[id] * Fetch a single workflow by ID - * Uses the authoritative Yjs-first workflow state loader. + * Reads through the editable Yjs session; saved DB tables only seed that session. */ export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const requestId = generateRequestId() @@ -123,32 +125,29 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } } - logger.debug(`[${requestId}] Attempting to load workflow ${workflowId} from authoritative state`) - const workflowState = await loadWorkflowState(workflowId, workflowData.lastSynced) + logger.debug(`[${requestId}] Attempting to load workflow ${workflowId} from Yjs session`) + const workflowState = await requireWorkflowRealtimeState(workflowId) if (!workflowState) { - logger.warn( - `[${requestId}] Workflow ${workflowId} has no stored state, returning empty state` - ) - } else { - logger.debug(`[${requestId}] Found ${workflowState.source} workflow state for ${workflowId}:`, { - blocksCount: Object.keys(workflowState.blocks).length, - edgesCount: workflowState.edges.length, - loopsCount: Object.keys(workflowState.loops).length, - parallelsCount: Object.keys(workflowState.parallels).length, - loops: workflowState.loops, - }) + logger.warn(`[${requestId}] Workflow ${workflowId} is missing saved state`) + return NextResponse.json({ error: 'Workflow state is missing' }, { status: 409 }) } - const resolvedState = workflowState - ? createWorkflowSnapshot({ - direction: workflowState.direction, - blocks: workflowState.blocks, - edges: workflowState.edges, - loops: workflowState.loops, - parallels: workflowState.parallels, - }) - : createWorkflowSnapshot() + logger.debug(`[${requestId}] Found editable Yjs workflow state for ${workflowId}:`, { + blocksCount: Object.keys(workflowState.blocks).length, + edgesCount: workflowState.edges.length, + loopsCount: Object.keys(workflowState.loops).length, + parallelsCount: Object.keys(workflowState.parallels).length, + loops: workflowState.loops, + }) + + const resolvedState = createWorkflowSnapshot({ + direction: workflowState.direction, + blocks: workflowState.blocks, + edges: workflowState.edges, + loops: workflowState.loops, + parallels: workflowState.parallels, + }) let resolvedBlocks = resolvedState.blocks if (!isInternalCall && resolvedState.blocks) { @@ -163,6 +162,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ const finalWorkflowData = { ...workflowData, + ...(workflowState.name !== undefined ? { name: workflowState.name } : {}), + ...(workflowState.description !== undefined + ? { description: workflowState.description } + : {}), + ...(workflowState.folderId !== undefined ? { folderId: workflowState.folderId } : {}), state: { deploymentStatuses: {}, ...(resolvedState.direction !== undefined ? { direction: resolvedState.direction } : {}), @@ -173,13 +177,11 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ lastSaved: Date.now(), isDeployed: workflowData.isDeployed || false, deployedAt: workflowData.deployedAt, - variables: workflowState?.variables ?? {}, + variables: workflowState.variables, }, } - logger.info( - `[${requestId}] Loaded workflow ${workflowId} from ${workflowState?.source ?? 'empty state'}` - ) + logger.info(`[${requestId}] Loaded editable workflow ${workflowId} from Yjs`) const elapsed = Date.now() - startTime logger.info(`[${requestId}] Successfully fetched workflow ${workflowId} in ${elapsed}ms`) @@ -187,6 +189,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } catch (error: any) { const elapsed = Date.now() - startTime logger.error(`[${requestId}] Error fetching workflow ${workflowId} after ${elapsed}ms`, error) + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } @@ -286,17 +290,7 @@ export async function DELETE( } await db.delete(workflow).where(eq(workflow.id, workflowId)) - - // Best-effort cleanup of the authoritative socket/Yjs session. - // Do not block workflow deletion if the bridge is unavailable. - try { - await deleteYjsSessionInSocketServer(workflowId) - } catch (error) { - logger.warn( - `[${requestId}] Failed to delete socket/Yjs session for workflow ${workflowId}`, - { error, workflowId } - ) - } + await deleteYjsSessionInSocketServer(workflowId).catch(() => undefined) const elapsed = Date.now() - startTime logger.info(`[${requestId}] Successfully deleted workflow ${workflowId} in ${elapsed}ms`) @@ -368,22 +362,19 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - // Build update object - const updateData: any = { updatedAt: new Date() } - if (updates.name !== undefined) updateData.name = updates.name - if (updates.description !== undefined) updateData.description = updates.description - if (updates.folderId !== undefined) updateData.folderId = updates.folderId - - // Update the workflow - const [updatedWorkflow] = await db - .update(workflow) - .set(updateData) - .where(eq(workflow.id, workflowId)) - .returning() + const metadata = { + ...(updates.name !== undefined ? { name: updates.name } : {}), + ...(updates.description !== undefined ? { description: updates.description } : {}), + ...(updates.folderId !== undefined ? { folderId: updates.folderId } : {}), + } + const updatedWorkflow = + Object.keys(metadata).length > 0 + ? await applyWorkflowMetadata(workflowId, metadata) + : workflowData const elapsed = Date.now() - startTime logger.info(`[${requestId}] Successfully updated workflow ${workflowId} in ${elapsed}ms`, { - updates: updateData, + updates, }) return NextResponse.json({ workflow: updatedWorkflow }, { status: 200 }) @@ -400,6 +391,8 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ } logger.error(`[${requestId}] Error updating workflow ${workflowId} after ${elapsed}ms`, error) + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } diff --git a/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts deleted file mode 100644 index 5de1bb481..000000000 --- a/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { NextRequest } from 'next/server' -/** - * @vitest-environment node - */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -describe('Workflow State API Route', () => { - let loadWorkflowStateFromYjsMock: ReturnType - let saveWorkflowToNormalizedTablesMock: ReturnType - let tryApplyWorkflowStateMock: ReturnType - let updateSetMock: ReturnType - - const createRequest = (body: Record) => - new NextRequest('http://localhost:3000/api/workflows/workflow-id/state', { - method: 'PUT', - body: JSON.stringify(body), - headers: { - 'Content-Type': 'application/json', - }, - }) - - const validStateBody = { - blocks: { - 'block-1': { - id: 'block-1', - type: 'agent', - name: 'Agent', - position: { x: 0, y: 0 }, - subBlocks: {}, - outputs: {}, - enabled: true, - }, - }, - edges: [], - loops: {}, - parallels: {}, - } - - beforeEach(() => { - vi.resetModules() - vi.clearAllMocks() - - loadWorkflowStateFromYjsMock = vi.fn().mockResolvedValue(null) - saveWorkflowToNormalizedTablesMock = vi.fn().mockResolvedValue({ success: true }) - tryApplyWorkflowStateMock = vi.fn().mockResolvedValue({ success: true }) - updateSetMock = vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue(undefined), - }) - - vi.doMock('drizzle-orm', () => ({ - eq: vi.fn((field, value) => ({ field, value })), - })) - - vi.doMock('@tradinggoose/db/schema', () => ({ - workflow: { - id: 'id', - }, - })) - - vi.doMock('@tradinggoose/db', () => ({ - db: { - update: vi.fn().mockReturnValue({ - set: updateSetMock, - }), - }, - })) - - vi.doMock('@/lib/auth', () => ({ - getSession: vi.fn().mockResolvedValue({ - user: { id: 'user-id' }, - }), - })) - - vi.doMock('@/lib/logs/console/logger', () => ({ - createLogger: vi.fn(() => ({ - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - })), - })) - - vi.doMock('@/lib/utils', () => ({ - generateRequestId: vi.fn(() => 'request-id'), - })) - - vi.doMock('@/lib/workflows/utils', () => ({ - validateWorkflowPermissions: vi.fn().mockResolvedValue({ - error: null, - session: { user: { id: 'user-id' } }, - workflow: { - id: 'workflow-id', - workspaceId: 'workspace-id', - variables: { - 'db-var': { - id: 'db-var', - workflowId: 'workflow-id', - name: 'dbVar', - type: 'plain', - value: 'db value', - }, - }, - }, - }), - })) - - vi.doMock('@/lib/workflows/validation', () => ({ - sanitizeAgentToolsInBlocks: vi.fn((blocks) => ({ - blocks, - warnings: [], - })), - })) - - vi.doMock('@/lib/workflows/db-helpers', () => ({ - loadWorkflowStateFromYjs: loadWorkflowStateFromYjsMock, - saveWorkflowToNormalizedTables: saveWorkflowToNormalizedTablesMock, - toISOStringOrUndefined: vi.fn((value: string | number | Date | null | undefined) => - value == null ? undefined : new Date(value).toISOString() - ), - })) - - vi.doMock('@/lib/workflows/custom-tools-persistence', () => ({ - extractAndPersistCustomTools: vi.fn().mockResolvedValue({ - saved: 0, - errors: [], - }), - })) - - vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ - tryApplyWorkflowState: tryApplyWorkflowStateMock, - })) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - it('falls back to authoritative Yjs variables when the request body omits them', async () => { - loadWorkflowStateFromYjsMock.mockResolvedValueOnce({ - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - variables: { - 'live-var': { - id: 'live-var', - workflowId: 'workflow-id', - name: 'liveVar', - type: 'plain', - value: 'live value', - }, - }, - lastSaved: Date.now(), - }) - - const { PUT } = await import('@/app/api/workflows/[id]/state/route') - const response = await PUT(createRequest(validStateBody), { - params: Promise.resolve({ id: 'workflow-id' }), - }) - - expect(response.status).toBe(200) - expect(tryApplyWorkflowStateMock).toHaveBeenCalledWith( - 'workflow-id', - expect.any(Object), - { - 'live-var': expect.objectContaining({ - name: 'liveVar', - value: 'live value', - }), - }, - undefined - ) - expect(updateSetMock).toHaveBeenCalledWith( - expect.objectContaining({ - variables: { - 'live-var': expect.objectContaining({ - name: 'liveVar', - value: 'live value', - }), - }, - }) - ) - }) - - it('does not republish workflow-row variables when no Yjs state is available in-process', async () => { - const { PUT } = await import('@/app/api/workflows/[id]/state/route') - const response = await PUT(createRequest(validStateBody), { - params: Promise.resolve({ id: 'workflow-id' }), - }) - - expect(response.status).toBe(200) - expect(tryApplyWorkflowStateMock).not.toHaveBeenCalled() - expect(updateSetMock).toHaveBeenCalledWith( - expect.not.objectContaining({ - variables: expect.anything(), - }) - ) - }) - - it('continues saving when authoritative Yjs variable lookup fails', async () => { - loadWorkflowStateFromYjsMock.mockRejectedValueOnce(new Error('socket bridge unavailable')) - - const { PUT } = await import('@/app/api/workflows/[id]/state/route') - const response = await PUT(createRequest(validStateBody), { - params: Promise.resolve({ id: 'workflow-id' }), - }) - - expect(response.status).toBe(200) - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledWith( - 'workflow-id', - expect.any(Object) - ) - expect(tryApplyWorkflowStateMock).not.toHaveBeenCalled() - expect(updateSetMock).toHaveBeenCalledWith( - expect.not.objectContaining({ - variables: expect.anything(), - }) - ) - }) - - it('does not apply Yjs state when the canonical save fails', async () => { - saveWorkflowToNormalizedTablesMock.mockResolvedValueOnce({ - success: false, - error: 'validation failed', - }) - - const { PUT } = await import('@/app/api/workflows/[id]/state/route') - const response = await PUT(createRequest(validStateBody), { - params: Promise.resolve({ id: 'workflow-id' }), - }) - - expect(response.status).toBe(500) - expect(tryApplyWorkflowStateMock).not.toHaveBeenCalled() - }) -}) diff --git a/apps/tradinggoose/app/api/workflows/[id]/state/route.ts b/apps/tradinggoose/app/api/workflows/[id]/state/route.ts deleted file mode 100644 index 1219f2d28..000000000 --- a/apps/tradinggoose/app/api/workflows/[id]/state/route.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { db } from '@tradinggoose/db' -import { workflow } from '@tradinggoose/db/schema' -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { createLogger } from '@/lib/logs/console/logger' -import { generateRequestId } from '@/lib/utils' -import { extractAndPersistCustomTools } from '@/lib/workflows/custom-tools-persistence' -import { - loadWorkflowStateFromYjs, - saveWorkflowToNormalizedTables, - toISOStringOrUndefined, -} from '@/lib/workflows/db-helpers' -import { validateWorkflowPermissions } from '@/lib/workflows/utils' -import { sanitizeAgentToolsInBlocks } from '@/lib/workflows/validation' -import { tryApplyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' -import type { WorkflowSnapshot } from '@/lib/yjs/workflow-session' - -const logger = createLogger('WorkflowStateAPI') - -const PositionSchema = z.object({ - x: z.number(), - y: z.number(), -}) - -const BlockDataSchema = z.object({ - parentId: z.string().optional(), - extent: z.literal('parent').optional(), - width: z.number().optional(), - height: z.number().optional(), - collection: z.unknown().optional(), - count: z.number().optional(), - loopType: z.enum(['for', 'forEach', 'while', 'doWhile']).optional(), - whileCondition: z.string().optional(), - parallelType: z.enum(['collection', 'count']).optional(), - type: z.string().optional(), -}) - -const SubBlockStateSchema = z.object({ - id: z.string(), - type: z.string(), - value: z.any(), -}) - -const BlockOutputSchema = z.any() - -const BlockLayoutSchema = z.object({ - measuredWidth: z.number().optional(), - measuredHeight: z.number().optional(), -}) - -const BlockStateSchema = z.object({ - id: z.string(), - type: z.string(), - name: z.string(), - position: PositionSchema, - subBlocks: z.record(SubBlockStateSchema), - outputs: z.record(BlockOutputSchema), - enabled: z.boolean(), - horizontalHandles: z.boolean().optional(), - isWide: z.boolean().optional(), - height: z.number().optional(), - advancedMode: z.boolean().optional(), - triggerMode: z.boolean().optional(), - data: BlockDataSchema.optional(), - layout: BlockLayoutSchema.optional(), -}) - -const EdgeSchema = z.object({ - id: z.string(), - source: z.string(), - target: z.string(), - sourceHandle: z.string().optional(), - targetHandle: z.string().optional(), - type: z.string().optional(), - animated: z.boolean().optional(), - style: z.record(z.any()).optional(), - data: z.record(z.any()).optional(), - label: z.string().optional(), - labelStyle: z.record(z.any()).optional(), - labelShowBg: z.boolean().optional(), - labelBgStyle: z.record(z.any()).optional(), - labelBgPadding: z.array(z.number()).optional(), - labelBgBorderRadius: z.number().optional(), - markerStart: z.string().optional(), - markerEnd: z.string().optional(), -}) - -const LoopSchema = z.object({ - id: z.string(), - nodes: z.array(z.string()), - iterations: z.number(), - loopType: z.enum(['for', 'forEach', 'while', 'doWhile']), - forEachItems: z.union([z.array(z.any()), z.record(z.any()), z.string()]).optional(), - whileCondition: z.string().optional(), -}) - -const ParallelSchema = z.object({ - id: z.string(), - nodes: z.array(z.string()), - distribution: z.union([z.array(z.any()), z.record(z.any()), z.string()]).optional(), - count: z.number().optional(), - parallelType: z.enum(['count', 'collection']).optional(), -}) - -const WorkflowStateSchema = z.object({ - direction: z.enum(['TD', 'LR']).optional(), - blocks: z.record(BlockStateSchema), - edges: z.array(EdgeSchema), - loops: z.record(LoopSchema).optional(), - parallels: z.record(ParallelSchema).optional(), - lastSaved: z.number().optional(), - isDeployed: z.boolean().optional(), - deployedAt: z.coerce.date().optional(), - variables: z.record(z.any()).optional(), -}) - -type ResolvedVariables = { - value: Record | undefined - source: 'request' | 'yjs' | 'unavailable' -} - -/** - * PUT /api/workflows/[id]/state - * Save complete workflow state to normalized database tables - */ -export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const startTime = Date.now() - const { id: workflowId } = await params - - try { - const { - error, - session, - workflow: workflowData, - } = await validateWorkflowPermissions(workflowId, requestId, 'write') - if (error || !session?.user?.id || !workflowData) { - return NextResponse.json( - { error: error?.message ?? 'Unauthorized' }, - { status: error?.status ?? 401 } - ) - } - const userId = session.user.id - - // Parse and validate request body - const body = await request.json() - const state = WorkflowStateSchema.parse(body) - - // Sanitize custom tools in agent blocks before saving - const { blocks: sanitizedBlocks, warnings } = sanitizeAgentToolsInBlocks(state.blocks as any) - - // Filter out blocks without type or name before saving - const filteredBlocks = Object.entries(sanitizedBlocks).reduce( - (acc, [blockId, block]: [string, any]) => { - if (!block?.type) { - logger.warn(`[${requestId}] Skipping block ${blockId} due to missing type`) - return acc - } - - acc[blockId] = { - ...block, - id: block.id || blockId, - name: typeof block.name === 'string' ? block.name : '', - enabled: block.enabled !== undefined ? block.enabled : true, - horizontalHandles: block.horizontalHandles !== undefined ? block.horizontalHandles : true, - isWide: block.isWide !== undefined ? block.isWide : false, - height: block.height !== undefined ? block.height : 0, - subBlocks: block.subBlocks || {}, - outputs: block.outputs || {}, - } - - return acc - }, - {} as typeof state.blocks - ) - - const workflowState = { - ...(state.direction !== undefined ? { direction: state.direction } : {}), - blocks: filteredBlocks, - edges: state.edges, - loops: state.loops || {}, - parallels: state.parallels || {}, - lastSaved: toISOStringOrUndefined(state.lastSaved) ?? new Date().toISOString(), - isDeployed: state.isDeployed || false, - deployedAt: toISOStringOrUndefined(state.deployedAt), - } - - // Preserve variables only from the request body or the authoritative Yjs - // workflow state loader. Falling back to the workflow row is unsafe when - // Next.js and the socket server run as separate processes because the row - // may lag behind newer variable edits that exist only in the socket - // server's live Yjs doc. - let resolvedVariables: ResolvedVariables = { - value: state.variables, - source: state.variables === undefined ? 'unavailable' : 'request', - } - if (resolvedVariables.value === undefined) { - try { - const yjsState = await loadWorkflowStateFromYjs(workflowId) - if (yjsState) { - resolvedVariables = { - value: yjsState.variables, - source: 'yjs', - } - } - } catch (error) { - logger.warn( - `[${requestId}] Skipping authoritative variable lookup for ${workflowId} because the Yjs bridge was unavailable`, - { error } - ) - } - } - - const saveResult = await saveWorkflowToNormalizedTables(workflowId, workflowState as any) - - if (!saveResult.success) { - logger.error(`[${requestId}] Failed to save workflow ${workflowId} state:`, saveResult.error) - return NextResponse.json( - { error: 'Failed to save workflow state', details: saveResult.error }, - { status: 500 } - ) - } - - const persistedWorkflowState = saveResult.normalizedState ?? workflowState - - // Apply the validated state to Yjs only when we can also preserve the - // current variables snapshot. Otherwise this process might publish a - // partial doc and wipe newer variables owned by the separate socket server. - if (resolvedVariables.source !== 'unavailable') { - await tryApplyWorkflowState( - workflowId, - persistedWorkflowState as WorkflowSnapshot, - resolvedVariables.value, - workflowData.name - ) - } else { - logger.warn( - `[${requestId}] Skipping Yjs workflow apply because no authoritative Yjs variables were available for ${workflowId}` - ) - } - - // Extract and persist custom tools to database - try { - const { saved, errors } = await extractAndPersistCustomTools( - persistedWorkflowState, - workflowData.workspaceId ?? null, - userId - ) - - if (saved > 0) { - logger.info(`[${requestId}] Persisted ${saved} custom tool(s) to database`, { workflowId }) - } - - if (errors.length > 0) { - logger.warn(`[${requestId}] Some custom tools failed to persist`, { errors, workflowId }) - } - } catch (error) { - logger.error(`[${requestId}] Failed to persist custom tools`, { error, workflowId }) - } - - // Update workflow metadata and persist variables - const syncedAt = new Date(workflowState.lastSaved) - await db - .update(workflow) - .set({ - lastSynced: syncedAt, - updatedAt: syncedAt, - ...(resolvedVariables.source !== 'unavailable' - ? { variables: resolvedVariables.value ?? {} } - : {}), - }) - .where(eq(workflow.id, workflowId)) - - const elapsed = Date.now() - startTime - logger.info(`[${requestId}] Successfully saved workflow ${workflowId} state in ${elapsed}ms`) - - return NextResponse.json({ success: true, warnings }, { status: 200 }) - } catch (error: any) { - const elapsed = Date.now() - startTime - logger.error( - `[${requestId}] Error saving workflow ${workflowId} state after ${elapsed}ms`, - error - ) - - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: 'Invalid request body', details: error.errors }, - { status: 400 } - ) - } - - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts index fcfaa4240..bc5b265b0 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.test.ts @@ -51,10 +51,13 @@ describe('Workflow Status API Route', () => { createErrorResponse: vi.fn((error, status) => Response.json({ success: false, error }, { status }) ), + createWorkflowRealtimeRequiredResponse: vi.fn(() => null), })) vi.doMock('@/lib/workflows/db-helpers', () => ({ - loadWorkflowState: mockLoadWorkflowState, + WORKFLOW_REALTIME_REQUIRED_CODE: 'WORKFLOW_REALTIME_REQUIRED', + isWorkflowRealtimeRequiredError: vi.fn(() => false), + requireWorkflowRealtimeState: mockLoadWorkflowState, })) vi.doMock('@/lib/workflows/utils', () => ({ @@ -96,10 +99,7 @@ describe('Workflow Status API Route', () => { vi.unstubAllGlobals() }) - it( - 'marks variable-only edits as needing redeployment', - { timeout: 10_000 }, - async () => { + it('marks variable-only edits as needing redeployment', { timeout: 10_000 }, async () => { mockValidateWorkflowAccess.mockResolvedValue({ error: null, workflow: { @@ -121,7 +121,6 @@ describe('Workflow Status API Route', () => { value: 'us-west-2', }, }, - source: 'normalized', }) mockLimit.mockResolvedValue([ @@ -152,8 +151,7 @@ describe('Workflow Status API Route', () => { const data = await response.json() expect(data.data.needsRedeployment).toBe(true) - } - ) + }) it('reports redeployment when the active deployment state omits current variables', async () => { mockValidateWorkflowAccess.mockResolvedValue({ @@ -177,7 +175,6 @@ describe('Workflow Status API Route', () => { value: 'us-west-2', }, }, - source: 'normalized', }) mockLimit.mockResolvedValue([ @@ -202,4 +199,29 @@ describe('Workflow Status API Route', () => { const data = await response.json() expect(data.data.needsRedeployment).toBe(true) }) + + it('returns conflict when deployed workflow editable state is missing', async () => { + mockValidateWorkflowAccess.mockResolvedValue({ + error: null, + workflow: { + isDeployed: true, + deployedAt: null, + isPublished: false, + }, + }) + mockLoadWorkflowState.mockResolvedValue(null) + mockLimit.mockResolvedValue([{ state: { blocks: {}, edges: [], loops: {}, parallels: {} } }]) + + const request = new NextRequest('http://localhost:3000/api/workflows/workflow-123/status') + const params = Promise.resolve({ id: 'workflow-123' }) + + const { GET } = await import('@/app/api/workflows/[id]/status/route') + const response = await GET(request, { params }) + + expect(response.status).toBe(409) + expect(await response.json()).toMatchObject({ + success: false, + error: 'Workflow state is missing', + }) + }) }) diff --git a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts index 38e4345ff..a50b260c7 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/status/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/status/route.ts @@ -3,10 +3,14 @@ import { and, desc, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { loadWorkflowState } from '@/lib/workflows/db-helpers' +import { requireWorkflowRealtimeState } from '@/lib/workflows/db-helpers' import { hasWorkflowChanged } from '@/lib/workflows/utils' import { validateWorkflowAccess } from '@/app/api/workflows/middleware' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' +import { + createErrorResponse, + createSuccessResponse, + createWorkflowRealtimeRequiredResponse, +} from '@/app/api/workflows/utils' const logger = createLogger('WorkflowStatusAPI') @@ -26,10 +30,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ let needsRedeployment = false if (validation.workflow.isDeployed) { - // Load current state (Yjs-first, fall back to normalized tables) and - // the active deployment version in parallel. + // Load current workflow state and the active deployment version in parallel. const [currentState, [active]] = await Promise.all([ - loadWorkflowState(id), + requireWorkflowRealtimeState(id), db .select({ state: workflowDeploymentVersion.state }) .from(workflowDeploymentVersion) @@ -44,7 +47,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ ]) if (!currentState) { - return createErrorResponse('Failed to load workflow state', 500) + logger.warn(`[${requestId}] Workflow ${id} is missing editable state`) + return createErrorResponse('Workflow state is missing', 409) } if (active?.state) { @@ -60,6 +64,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ }) } catch (error) { logger.error(`[${requestId}] Error getting status for workflow: ${(await params).id}`, error) + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse return createErrorResponse('Failed to get status', 500) } } diff --git a/apps/tradinggoose/app/api/workflows/middleware.ts b/apps/tradinggoose/app/api/workflows/middleware.ts index 0f5bf3e15..750db7c5d 100644 --- a/apps/tradinggoose/app/api/workflows/middleware.ts +++ b/apps/tradinggoose/app/api/workflows/middleware.ts @@ -1,8 +1,8 @@ import type { NextRequest } from 'next/server' -import { authenticateApiKey } from '@/lib/api-key/auth' import { type ApiKeyAuthResult, authenticateApiKeyFromHeader, + storedApiKeyMatches, updateApiKeyLastUsed, } from '@/lib/api-key/service' import { env } from '@/lib/env' @@ -67,7 +67,18 @@ export async function validateWorkflowAccess( // If a pinned key exists, only accept that specific key if (workflow.pinnedApiKey?.key) { - const isValidPinnedKey = await authenticateApiKey(apiKeyHeader, workflow.pinnedApiKey.key) + if ( + workflow.pinnedApiKey.type !== 'personal' && + workflow.pinnedApiKey.type !== 'workspace' + ) { + return { + error: { + message: 'Unauthorized: Invalid API key', + status: 401, + }, + } + } + const isValidPinnedKey = await storedApiKeyMatches(apiKeyHeader, workflow.pinnedApiKey.key) if (!isValidPinnedKey) { return { error: { @@ -82,45 +93,45 @@ export async function validateWorkflowAccess( success: true, userId: workflow.pinnedApiKey.userId, keyId: workflow.pinnedApiKey.id, - keyType: workflow.pinnedApiKey.type === 'workspace' ? 'workspace' : 'personal', + keyType: workflow.pinnedApiKey.type, workspaceId: workflow.pinnedApiKey.workspaceId || undefined, }, } + } + + // Try personal keys first + const personalResult = await authenticateApiKeyFromHeader(apiKeyHeader, { + userId: workflow.userId as string, + keyTypes: ['personal'], + }) + + let validResult = null + if (personalResult.success) { + validResult = personalResult } else { - // Try personal keys first - const personalResult = await authenticateApiKeyFromHeader(apiKeyHeader, { - userId: workflow.userId as string, - keyTypes: ['personal'], + // Try workspace keys + const workspaceResult = await authenticateApiKeyFromHeader(apiKeyHeader, { + workspaceId: workflow.workspaceId as string, + keyTypes: ['workspace'], }) - let validResult = null - if (personalResult.success) { - validResult = personalResult - } else { - // Try workspace keys - const workspaceResult = await authenticateApiKeyFromHeader(apiKeyHeader, { - workspaceId: workflow.workspaceId as string, - keyTypes: ['workspace'], - }) - - if (workspaceResult.success) { - validResult = workspaceResult - } + if (workspaceResult.success) { + validResult = workspaceResult } + } - // If no valid key found, reject - if (!validResult) { - return { - error: { - message: 'Unauthorized: Invalid API key', - status: 401, - }, - } + // If no valid key found, reject + if (!validResult) { + return { + error: { + message: 'Unauthorized: Invalid API key', + status: 401, + }, } - - await updateApiKeyLastUsed(validResult.keyId!) - return { workflow, apiKeyAuth: validResult } } + + await updateApiKeyLastUsed(validResult.keyId!) + return { workflow, apiKeyAuth: validResult } } return { workflow } } catch (error) { diff --git a/apps/tradinggoose/app/api/workflows/route.test.ts b/apps/tradinggoose/app/api/workflows/route.test.ts index d10cb019b..d388e6b16 100644 --- a/apps/tradinggoose/app/api/workflows/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/route.test.ts @@ -7,8 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' describe('Workflow API Route', () => { const insertValuesMock = vi.fn() const deleteWhereMock = vi.fn() - const saveWorkflowToNormalizedTablesMock = vi.fn() - const tryApplyWorkflowStateMock = vi.fn() + const applyWorkflowStateMock = vi.fn() const randomUUIDMock = vi.fn() const createRequest = (body: Record) => @@ -26,8 +25,7 @@ describe('Workflow API Route', () => { insertValuesMock.mockResolvedValue(undefined) deleteWhereMock.mockResolvedValue(undefined) - saveWorkflowToNormalizedTablesMock.mockResolvedValue({ success: true }) - tryApplyWorkflowStateMock.mockResolvedValue({ success: true }) + applyWorkflowStateMock.mockResolvedValue(undefined) randomUUIDMock.mockReset() randomUUIDMock.mockReturnValueOnce('workflow-123').mockReturnValueOnce('variable-123') vi.stubGlobal('crypto', { @@ -86,28 +84,12 @@ describe('Workflow API Route', () => { generateRequestId: vi.fn(() => 'request-id'), })) - vi.doMock('@/lib/workflows/db-helpers', () => ({ - remapVariableIds: vi.fn((variables: Record, workflowId: string) => - Object.fromEntries( - Object.entries(variables).map(([key, variable]) => [ - key, - { - ...variable, - id: crypto.randomUUID(), - workflowId, - }, - ]) - ) - ), - saveWorkflowToNormalizedTables: saveWorkflowToNormalizedTablesMock, - })) - vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ - tryApplyWorkflowState: tryApplyWorkflowStateMock, + applyWorkflowState: applyWorkflowStateMock, })) - vi.doMock('@/lib/yjs/workflow-session', () => ({ - createWorkflowSnapshot: vi.fn((snapshot) => snapshot), + vi.doMock('@/app/api/workflows/utils', () => ({ + createWorkflowRealtimeRequiredResponse: vi.fn(() => null), })) vi.doMock('@/lib/telemetry/tracer', () => ({ @@ -119,7 +101,7 @@ describe('Workflow API Route', () => { vi.unstubAllGlobals() }) - it('persists initial workflow state canonically before seeding Yjs', async () => { + it('applies initial workflow state through Yjs materialization', async () => { const initialWorkflowState = { blocks: { 'block-1': { @@ -158,13 +140,13 @@ describe('Workflow API Route', () => { expect(response.status).toBe(200) expect(insertValuesMock).toHaveBeenCalledOnce() - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() - expect(tryApplyWorkflowStateMock).toHaveBeenCalledOnce() + expect(applyWorkflowStateMock).toHaveBeenCalledOnce() const insertedWorkflow = insertValuesMock.mock.calls[0][0] - const canonicalState = saveWorkflowToNormalizedTablesMock.mock.calls[0][1] + const persistedState = applyWorkflowStateMock.mock.calls[0][1] + const persistedVariables = applyWorkflowStateMock.mock.calls[0][2] - const insertedVariableValues = Object.values(insertedWorkflow.variables as Record) + const insertedVariableValues = Object.values(persistedVariables as Record) expect(insertedVariableValues).toHaveLength(1) expect(insertedVariableValues[0]).toEqual({ id: 'variable-123', @@ -173,32 +155,25 @@ describe('Workflow API Route', () => { type: 'plain', value: 'secret', }) - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledWith( + expect(applyWorkflowStateMock).toHaveBeenCalledWith( insertedWorkflow.id, expect.objectContaining({ blocks: initialWorkflowState.blocks, edges: initialWorkflowState.edges, loops: initialWorkflowState.loops, parallels: initialWorkflowState.parallels, - isDeployed: false, - }) - ) - expect(canonicalState.lastSaved).toEqual(expect.any(Number)) - expect(tryApplyWorkflowStateMock).toHaveBeenCalledWith( - insertedWorkflow.id, - expect.objectContaining({ - blocks: initialWorkflowState.blocks, }), - insertedWorkflow.variables, - 'Workflow Copy' + persistedVariables, + expect.objectContaining({ + name: 'Workflow Copy', + description: 'Created from seed', + folderId: null, + }) ) }) - it('rolls back the workflow row when canonical initial-state persistence fails', async () => { - saveWorkflowToNormalizedTablesMock.mockResolvedValueOnce({ - success: false, - error: 'save failed', - }) + it('rolls back the workflow row when initial state persistence fails', async () => { + applyWorkflowStateMock.mockRejectedValueOnce(new Error('realtime unavailable')) const { POST } = await import('@/app/api/workflows/route') const response = await POST( @@ -216,9 +191,36 @@ describe('Workflow API Route', () => { ) expect(response.status).toBe(500) - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() + expect(applyWorkflowStateMock).toHaveBeenCalledOnce() expect(deleteWhereMock).toHaveBeenCalledOnce() - expect(tryApplyWorkflowStateMock).not.toHaveBeenCalled() + }) + + it('applies default workflow state when no initial state is provided', async () => { + const { POST } = await import('@/app/api/workflows/route') + const response = await POST( + createRequest({ + name: 'Blank Workflow', + workspaceId: 'workspace-1', + }) + ) + + expect(response.status).toBe(200) + expect(applyWorkflowStateMock).toHaveBeenCalledOnce() + + const insertedWorkflow = insertValuesMock.mock.calls[0][0] + const persistedVariables = applyWorkflowStateMock.mock.calls[0][2] + expect(persistedVariables).toEqual({}) + expect(applyWorkflowStateMock).toHaveBeenCalledWith( + insertedWorkflow.id, + expect.objectContaining({ + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + }), + {}, + expect.any(Object) + ) }) it('rejects workflow creation without workspace scope', async () => { diff --git a/apps/tradinggoose/app/api/workflows/route.ts b/apps/tradinggoose/app/api/workflows/route.ts index ca3b2ca30..e751fa9f8 100644 --- a/apps/tradinggoose/app/api/workflows/route.ts +++ b/apps/tradinggoose/app/api/workflows/route.ts @@ -8,10 +8,12 @@ import { getStableVibrantColor } from '@/lib/colors' import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' -import { remapVariableIds, saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' +import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' +import { remapVariableIds } from '@/lib/workflows/import-export' import { normalizeVariables } from '@/lib/workflows/variable-utils' -import { tryApplyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' +import { applyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' +import { createWorkflowRealtimeRequiredResponse } from '@/app/api/workflows/utils' import type { WorkflowState } from '@/stores/workflows/workflow/types' const logger = createLogger('WorkflowAPI') @@ -34,32 +36,30 @@ function getInitialWorkflowState( ): { canonicalState: WorkflowState variables: Record -} | null { - if (!isPlainObject(initialWorkflowState)) { - return null - } - - const blocks = isPlainObject(initialWorkflowState.blocks) ? initialWorkflowState.blocks : {} - const edges = Array.isArray(initialWorkflowState.edges) ? initialWorkflowState.edges : [] - const loops = isPlainObject(initialWorkflowState.loops) ? initialWorkflowState.loops : {} - const parallels = isPlainObject(initialWorkflowState.parallels) - ? initialWorkflowState.parallels - : {} - const variables = isPlainObject(initialWorkflowState.variables) - ? initialWorkflowState.variables - : {} +} { + const source = isPlainObject(initialWorkflowState) + ? initialWorkflowState + : buildDefaultWorkflowArtifacts().workflowState + const sourceRecord = source as Record + + const blocks = isPlainObject(sourceRecord.blocks) ? sourceRecord.blocks : {} + const edges = Array.isArray(sourceRecord.edges) ? sourceRecord.edges : [] + const loops = isPlainObject(sourceRecord.loops) ? sourceRecord.loops : {} + const parallels = isPlainObject(sourceRecord.parallels) ? sourceRecord.parallels : {} + const variables = isPlainObject(sourceRecord.variables) ? sourceRecord.variables : {} + const direction = + sourceRecord.direction === 'TD' || sourceRecord.direction === 'LR' + ? sourceRecord.direction + : undefined return { canonicalState: { + ...(direction ? { direction } : {}), blocks: blocks as WorkflowState['blocks'], edges: edges as WorkflowState['edges'], loops: loops as WorkflowState['loops'], parallels: parallels as WorkflowState['parallels'], lastSaved: now.getTime(), - isDeployed: false, - deployedAt: undefined, - deploymentStatuses: {}, - needsRedeployment: false, }, variables, } @@ -157,7 +157,7 @@ export async function POST(req: NextRequest) { const now = new Date() const initialState = getInitialWorkflowState(initialWorkflowState, now) const remappedVariables = remapVariableIds( - normalizeVariables(initialState?.variables), + normalizeVariables(initialState.variables), workflowId ) const resolvedColor = getStableVibrantColor(workflowId) @@ -190,39 +190,20 @@ export async function POST(req: NextRequest) { isDeployed: false, collaborators: [], runCount: 0, - variables: remappedVariables, isPublished: false, marketplaceData: null, }) - let persistedInitialState = initialState?.canonicalState ?? null - if (initialState) { - const saveResult = await saveWorkflowToNormalizedTables(workflowId, initialState.canonicalState) - if (!saveResult.success) { - await db.delete(workflow).where(eq(workflow.id, workflowId)) - throw new Error(saveResult.error || 'Failed to persist initial workflow state') - } - persistedInitialState = saveResult.normalizedState ?? initialState.canonicalState - } - - // Seed the Yjs doc for the new workflow - const defaultWorkflowSnapshot = createWorkflowSnapshot({ - blocks: persistedInitialState?.blocks, - edges: persistedInitialState?.edges, - loops: persistedInitialState?.loops, - parallels: persistedInitialState?.parallels, - lastSaved: now.toISOString(), - isDeployed: false, - }) - - const yjsSeedResult = await tryApplyWorkflowState( - workflowId, - defaultWorkflowSnapshot, - remappedVariables, - name - ) - if (yjsSeedResult.success) { - logger.info(`[${requestId}] Seeded Yjs doc for new workflow ${workflowId}`) + try { + await applyWorkflowState( + workflowId, + createWorkflowSnapshot(initialState.canonicalState), + remappedVariables, + { name, description, folderId: folderId || null } + ) + } catch (error) { + await db.delete(workflow).where(eq(workflow.id, workflowId)) + throw error } logger.info(`[${requestId}] Successfully created workflow ${workflowId}`) @@ -238,6 +219,9 @@ export async function POST(req: NextRequest) { updatedAt: now, }) } catch (error) { + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse + if (error instanceof z.ZodError) { logger.warn(`[${requestId}] Invalid workflow creation data`, { errors: error.errors, diff --git a/apps/tradinggoose/app/api/workflows/utils.ts b/apps/tradinggoose/app/api/workflows/utils.ts index 75ee1ab97..36610ba66 100644 --- a/apps/tradinggoose/app/api/workflows/utils.ts +++ b/apps/tradinggoose/app/api/workflows/utils.ts @@ -1,4 +1,8 @@ import { NextResponse } from 'next/server' +import { + isWorkflowRealtimeRequiredError, + WORKFLOW_REALTIME_REQUIRED_CODE, +} from '@/lib/workflows/db-helpers' export function createErrorResponse(error: string, status: number, code?: string) { return NextResponse.json( @@ -13,3 +17,12 @@ export function createErrorResponse(error: string, status: number, code?: string export function createSuccessResponse(data: any) { return NextResponse.json(data) } + +export function createWorkflowRealtimeRequiredResponse(error: unknown) { + if (!isWorkflowRealtimeRequiredError(error)) return null + return createErrorResponse( + 'Editable workflow realtime orchestration is required', + 503, + WORKFLOW_REALTIME_REQUIRED_CODE + ) +} diff --git a/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts b/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts index 27d071673..be2c7be80 100644 --- a/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/yaml/export/route.test.ts @@ -92,19 +92,20 @@ describe('Workflow YAML Export API Route', () => { })) vi.doMock('@/lib/workflows/db-helpers', () => ({ - loadWorkflowState: loadWorkflowStateMock, + WORKFLOW_REALTIME_REQUIRED_CODE: 'WORKFLOW_REALTIME_REQUIRED', + isWorkflowRealtimeRequiredError: vi.fn(() => false), + requireWorkflowRealtimeState: loadWorkflowStateMock, })) - vi.doMock('@/lib/copilot/tools/client/workflow/block-output-utils', () => ({ + vi.doMock('@/lib/copilot/workflow/block-output-utils', () => ({ extractSubBlockValuesFromBlocks: vi.fn((blocks: Record) => Object.fromEntries( Object.entries(blocks).map(([blockId, block]) => [ blockId, Object.fromEntries( - Object.entries(block?.subBlocks || {}).map(([subBlockId, subBlock]: [string, any]) => [ - subBlockId, - subBlock?.value, - ]) + Object.entries(block?.subBlocks || {}).map( + ([subBlockId, subBlock]: [string, any]) => [subBlockId, subBlock?.value] + ) ), ]) ) @@ -130,43 +131,42 @@ describe('Workflow YAML Export API Route', () => { }) it( - 'prefers the live Yjs workflow snapshot and includes variables in the export payload', + 'uses the current workflow state and includes variables in the export payload', { timeout: 10_000 }, async () => { - loadWorkflowStateMock.mockResolvedValue({ - blocks: { - 'live-block': { - id: 'live-block', - type: 'agent', - name: 'Live Agent', - position: { x: 0, y: 0 }, - subBlocks: { - prompt: { id: 'prompt', type: 'long-input', value: 'live value' }, + loadWorkflowStateMock.mockResolvedValue({ + blocks: { + 'live-block': { + id: 'live-block', + type: 'agent', + name: 'Live Agent', + position: { x: 0, y: 0 }, + subBlocks: { + prompt: { id: 'prompt', type: 'long-input', value: 'live value' }, + }, + outputs: {}, + enabled: true, }, - outputs: {}, - enabled: true, }, - }, - edges: [], - loops: {}, - parallels: {}, - variables: { - 'live-var': { - id: 'live-var', - workflowId: 'workflow-id', - name: 'liveVar', - type: 'plain', - value: 'live', + edges: [], + loops: {}, + parallels: {}, + variables: { + 'live-var': { + id: 'live-var', + workflowId: 'workflow-id', + name: 'liveVar', + type: 'plain', + value: 'live', + }, }, - }, - lastSaved: Date.now(), - source: 'yjs', - }) + lastSaved: Date.now(), + }) - const { GET } = await import('@/app/api/workflows/yaml/export/route') - const response = await GET(createRequest()) + const { GET } = await import('@/app/api/workflows/yaml/export/route') + const response = await GET(createRequest()) - expect(response.status).toBe(200) + expect(response.status).toBe(200) expect(makeRequestMock).toHaveBeenCalledWith( '/api/workflow/to-yaml', expect.objectContaining({ @@ -193,7 +193,7 @@ describe('Workflow YAML Export API Route', () => { } ) - it('falls back to canonical saved state and workflow-row variables when no live doc exists', async () => { + it('exports the current workflow state', async () => { loadWorkflowStateMock.mockResolvedValue({ blocks: { 'db-block': { @@ -221,7 +221,6 @@ describe('Workflow YAML Export API Route', () => { }, }, lastSaved: Date.now(), - source: 'normalized', }) const { GET } = await import('@/app/api/workflows/yaml/export/route') diff --git a/apps/tradinggoose/app/api/workflows/yaml/export/route.ts b/apps/tradinggoose/app/api/workflows/yaml/export/route.ts index b65bc902d..96bbf44d7 100644 --- a/apps/tradinggoose/app/api/workflows/yaml/export/route.ts +++ b/apps/tradinggoose/app/api/workflows/yaml/export/route.ts @@ -3,12 +3,13 @@ import { workflow } from '@tradinggoose/db/schema' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { simAgentClient } from '@/lib/copilot/agent/client' +import { extractSubBlockValuesFromBlocks } from '@/lib/copilot/workflow/block-output-utils' import { createLogger } from '@/lib/logs/console/logger' import { checkWorkspaceAccess } from '@/lib/permissions/utils' -import { simAgentClient } from '@/lib/copilot/agent/client' import { generateRequestId } from '@/lib/utils' -import { loadWorkflowState } from '@/lib/workflows/db-helpers' -import { extractSubBlockValuesFromBlocks } from '@/lib/copilot/tools/client/workflow/block-output-utils' +import { requireWorkflowRealtimeState } from '@/lib/workflows/db-helpers' +import { createWorkflowRealtimeRequiredResponse } from '@/app/api/workflows/utils' import { getAllBlocks } from '@/blocks/registry' import type { BlockConfig } from '@/blocks/types' import { resolveOutputType } from '@/blocks/utils' @@ -37,7 +38,7 @@ export async function GET(request: NextRequest) { const userId = session.user.id - // Fetch the workflow from database + // Fetch workflow metadata for access checks. const workflowData = await db .select() .from(workflow) @@ -70,9 +71,9 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - const stateWithSource = await loadWorkflowState(workflowId) + const editableState = await requireWorkflowRealtimeState(workflowId) - if (!stateWithSource) { + if (!editableState) { return NextResponse.json( { success: false, error: 'Workflow has no state data' }, { status: 400 } @@ -81,17 +82,18 @@ export async function GET(request: NextRequest) { const workflowState: any = { deploymentStatuses: {}, - blocks: stateWithSource.blocks, - edges: stateWithSource.edges, - loops: stateWithSource.loops, - parallels: stateWithSource.parallels, - variables: stateWithSource.variables || {}, - lastSaved: stateWithSource.lastSaved ?? Date.now(), + ...(editableState.direction !== undefined ? { direction: editableState.direction } : {}), + blocks: editableState.blocks, + edges: editableState.edges, + loops: editableState.loops, + parallels: editableState.parallels, + variables: editableState.variables || {}, + lastSaved: editableState.lastSaved ?? Date.now(), isDeployed: workflowData.isDeployed ?? false, deployedAt: workflowData.deployedAt, } - logger.info(`[${requestId}] Loaded workflow ${workflowId} from ${stateWithSource.source}`, { + logger.info(`[${requestId}] Loaded editable workflow ${workflowId} from Yjs`, { blocksCount: Object.keys(workflowState.blocks).length, edgesCount: workflowState.edges.length, variablesCount: Object.keys(workflowState.variables || {}).length, @@ -180,6 +182,8 @@ export async function GET(request: NextRequest) { }) } catch (error) { logger.error(`[${requestId}] YAML export failed`, error) + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse return NextResponse.json( { success: false, diff --git a/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts b/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts index 6c43b8cf5..5db1cceaf 100644 --- a/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts +++ b/apps/tradinggoose/app/api/workspaces/[id]/api-keys/route.ts @@ -4,7 +4,11 @@ import { and, eq, inArray } from 'drizzle-orm' import { nanoid } from 'nanoid' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth' +import { + createApiKey, + getApiKeyDisplayFormat, + isApiKeyStorageAvailable, +} from '@/lib/api-key/service' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { getUserEntityPermissions } from '@/lib/permissions/utils' @@ -57,16 +61,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ .where(and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.type, 'workspace'))) .orderBy(apiKey.createdAt) - const formattedWorkspaceKeys = await Promise.all( - workspaceKeys.map(async (key) => { - const displayFormat = await getApiKeyDisplayFormat(key.key) - return { - ...key, - key: key.key, - displayKey: displayFormat, - } - }) - ) + const formattedWorkspaceKeys = workspaceKeys.flatMap(({ key, ...apiKey }) => { + const displayKey = getApiKeyDisplayFormat(key) + return displayKey ? [{ ...apiKey, displayKey }] : [] + }) return NextResponse.json({ keys: formattedWorkspaceKeys, @@ -122,10 +120,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ ) } - const { key: plainKey, encryptedKey } = await createApiKey(true) + if (!isApiKeyStorageAvailable()) { + return NextResponse.json({ error: 'API key access is not configured' }, { status: 503 }) + } - if (!encryptedKey) { - throw new Error('Failed to encrypt API key for storage') + const { key: plainKey, storedKey } = await createApiKey(true) + if (!storedKey) { + throw new Error('Failed to prepare API key for storage') } const [newKey] = await db @@ -136,7 +137,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ userId: userId, createdBy: userId, name, - key: encryptedKey, + key: storedKey, type: 'workspace', createdAt: new Date(), updatedAt: new Date(), diff --git a/apps/tradinggoose/app/api/workspaces/[id]/route.test.ts b/apps/tradinggoose/app/api/workspaces/[id]/route.test.ts index d1c06aff0..5c27ae8e6 100644 --- a/apps/tradinggoose/app/api/workspaces/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workspaces/[id]/route.test.ts @@ -101,6 +101,10 @@ vi.mock('@/lib/logs/console/logger', () => ({ createLogger: vi.fn(() => mockLogger), })) +vi.mock('@/lib/workspaces/service', () => ({ + getUserWorkspaces: vi.fn(), +})) + describe('Workspace by id PATCH route', () => { beforeEach(() => { vi.resetModules() diff --git a/apps/tradinggoose/app/api/workspaces/[id]/route.ts b/apps/tradinggoose/app/api/workspaces/[id]/route.ts index b4010a1d6..c8c7124b5 100644 --- a/apps/tradinggoose/app/api/workspaces/[id]/route.ts +++ b/apps/tradinggoose/app/api/workspaces/[id]/route.ts @@ -15,6 +15,7 @@ import { WorkspaceBillingOwnerUpdateError, workspaceBillingOwnerSchema, } from '@/lib/workspaces/billing-owner' +import { getUserWorkspaces } from '@/lib/workspaces/service' const logger = createLogger('WorkspaceByIdAPI') @@ -211,6 +212,11 @@ export async function DELETE( return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) } + const userWorkspaces = await getUserWorkspaces({ userId: session.user.id }) + if (userWorkspaces.length <= 1) { + return NextResponse.json({ error: 'Cannot delete your last workspace' }, { status: 400 }) + } + try { logger.info( `Deleting workspace ${workspaceId} for user ${session.user.id}, deleteTemplates: ${deleteTemplates}` diff --git a/apps/tradinggoose/app/api/workspaces/route.test.ts b/apps/tradinggoose/app/api/workspaces/route.test.ts index 1e282e324..c720aee00 100644 --- a/apps/tradinggoose/app/api/workspaces/route.test.ts +++ b/apps/tradinggoose/app/api/workspaces/route.test.ts @@ -1,24 +1,10 @@ /** * @vitest-environment node */ -import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' describe('Workspaces API Route', () => { const transactionMock = vi.fn() - const txInsertValuesMock = vi.fn() - const txInsertMock = vi.fn(() => ({ - values: txInsertValuesMock, - })) - const deleteWhereMock = vi.fn() - const deleteMock = vi.fn((_table: unknown) => ({ - where: deleteWhereMock, - })) - const updateWhereMock = vi.fn() - const updateSetMock = vi.fn() - const updateMock = vi.fn() - const mockSaveWorkflowToNormalizedTables = vi.fn() - const mockTryApplyWorkflowState = vi.fn() let userWorkspaces: Array<{ workspace: Record permissionType: 'admin' | 'write' | 'read' | null @@ -29,20 +15,8 @@ describe('Workspaces API Route', () => { vi.clearAllMocks() userWorkspaces = [] - txInsertValuesMock.mockResolvedValue(undefined) - transactionMock.mockImplementation(async (callback) => - callback({ insert: txInsertMock, delete: deleteMock }) - ) - deleteWhereMock.mockResolvedValue(undefined) - updateWhereMock.mockResolvedValue([]) - updateSetMock.mockReturnValue({ where: updateWhereMock }) - updateMock.mockReturnValue({ set: updateSetMock }) - mockSaveWorkflowToNormalizedTables.mockResolvedValue({ success: true }) - mockTryApplyWorkflowState.mockResolvedValue({ success: true }) - vi.doMock('@tradinggoose/db', () => ({ db: { - delete: deleteMock, select: vi.fn(() => ({ from: vi.fn(() => ({ leftJoin: vi.fn(() => ({ @@ -57,8 +31,10 @@ describe('Workspaces API Route', () => { })), })), })), - update: updateMock, transaction: transactionMock, + insert: vi.fn(() => ({ + values: vi.fn().mockResolvedValue(undefined), + })), }, })) @@ -69,11 +45,6 @@ describe('Workspaces API Route', () => { entityType: 'permissions.entityType', entityId: 'permissions.entityId', }, - workflow: { - id: 'workflow.id', - userId: 'workflow.userId', - workspaceId: 'workflow.workspaceId', - }, workspace: { id: 'workspace.id', ownerId: 'workspace.ownerId', @@ -97,29 +68,6 @@ describe('Workspaces API Route', () => { })), })) - vi.doMock('@/lib/workflows/defaults', () => ({ - buildDefaultWorkflowArtifacts: vi.fn(() => ({ - workflowState: { - blocks: {}, - edges: [], - loops: {}, - parallels: {}, - }, - })), - })) - - vi.doMock('@/lib/workflows/db-helpers', () => ({ - saveWorkflowToNormalizedTables: mockSaveWorkflowToNormalizedTables, - })) - - vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ - tryApplyWorkflowState: mockTryApplyWorkflowState, - })) - - vi.doMock('@/lib/yjs/workflow-session', () => ({ - createWorkflowSnapshot: vi.fn(() => ({})), - })) - vi.doMock('@/lib/workspaces/billing-owner', () => ({ toWorkspaceApiRecord: vi.fn((workspace) => ({ ...workspace, @@ -137,28 +85,17 @@ describe('Workspaces API Route', () => { vi.clearAllMocks() }) - async function postWorkspace() { - const { POST } = await import('@/app/api/workspaces/route') - return POST( - new Request('http://localhost/api/workspaces', { - method: 'POST', - body: JSON.stringify({ name: 'New Workspace' }), - }) - ) - } - - it('returns an empty list without creating a default workspace when autoCreate=false', async () => { + it('returns an empty list without creating a default workspace during reads', async () => { const { GET } = await import('@/app/api/workspaces/route') - const response = await GET(new NextRequest('http://localhost/api/workspaces?autoCreate=false')) + const response = await GET() expect(response.status).toBe(200) expect(await response.json()).toEqual({ workspaces: [] }) expect(transactionMock).not.toHaveBeenCalled() - expect(updateMock).not.toHaveBeenCalled() }) - it('lists existing workspaces without running workspace migration side effects when autoCreate=false', async () => { + it('lists existing workspaces without running migration side effects', async () => { userWorkspaces = [ { workspace: { @@ -177,7 +114,7 @@ describe('Workspaces API Route', () => { const { GET } = await import('@/app/api/workspaces/route') - const response = await GET(new NextRequest('http://localhost/api/workspaces?autoCreate=false')) + const response = await GET() const data = await response.json() expect(response.status).toBe(200) @@ -192,7 +129,6 @@ describe('Workspaces API Route', () => { role: 'owner', permissions: 'admin', }) - expect(updateMock).not.toHaveBeenCalled() expect(transactionMock).not.toHaveBeenCalled() }) @@ -215,7 +151,7 @@ describe('Workspaces API Route', () => { const { GET } = await import('@/app/api/workspaces/route') - const response = await GET(new NextRequest('http://localhost/api/workspaces?autoCreate=false')) + const response = await GET() const data = await response.json() expect(response.status).toBe(200) @@ -228,60 +164,4 @@ describe('Workspaces API Route', () => { ]) expect(transactionMock).not.toHaveBeenCalled() }) - - it('auto-creates a default workspace with the canonical workspace shape', async () => { - const { GET } = await import('@/app/api/workspaces/route') - - const response = await GET(new NextRequest('http://localhost/api/workspaces')) - const data = await response.json() - - expect(response.status).toBe(200) - expect(data.workspaces).toEqual([ - expect.objectContaining({ - name: "Bruz's Workspace", - role: 'owner', - permissions: 'admin', - billingOwner: { - type: 'user', - userId: 'user-1', - }, - }), - ]) - expect(transactionMock).toHaveBeenCalled() - expect(updateMock).toHaveBeenCalled() - }) - - it.each([ - [ - 'persistence fails', - () => - mockSaveWorkflowToNormalizedTables.mockResolvedValue({ - success: false, - error: 'Failed to persist normalized workflow state', - }), - ], - [ - 'persistence throws', - () => mockSaveWorkflowToNormalizedTables.mockRejectedValue(new Error('database unavailable')), - ], - [ - 'Yjs seeding fails', - () => - mockTryApplyWorkflowState.mockResolvedValue({ - success: false, - error: new Error('socket unavailable'), - }), - ], - ])('removes a newly created workspace when default workflow %s', async (_case, fail) => { - fail() - const response = await postWorkspace() - - expect(response.status).toBe(500) - expect(await response.json()).toEqual({ error: 'Failed to create workspace' }) - expect(deleteMock.mock.calls.map(([table]) => table)).toEqual([ - expect.objectContaining({ workspaceId: 'workflow.workspaceId' }), - expect.objectContaining({ ownerId: 'workspace.ownerId' }), - ]) - expect(deleteWhereMock).toHaveBeenCalledTimes(2) - }) }) diff --git a/apps/tradinggoose/app/api/workspaces/route.ts b/apps/tradinggoose/app/api/workspaces/route.ts index 444eee6f4..79a61c0d8 100644 --- a/apps/tradinggoose/app/api/workspaces/route.ts +++ b/apps/tradinggoose/app/api/workspaces/route.ts @@ -1,4 +1,4 @@ -import { type NextRequest, NextResponse } from 'next/server' +import { NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' @@ -10,9 +10,8 @@ const createWorkspaceSchema = z.object({ }) // Get all workspaces for the current user -export async function GET(request: NextRequest) { +export async function GET() { const session = await getSession() - const allowWorkspaceBootstrap = request.nextUrl.searchParams.get('autoCreate') !== 'false' if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -20,8 +19,6 @@ export async function GET(request: NextRequest) { const workspaces = await getUserWorkspaces({ userId: session.user.id, - userName: session.user.name, - autoCreate: allowWorkspaceBootstrap, }) return NextResponse.json({ workspaces }) diff --git a/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts b/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts index 9273c4d0d..a37f9308f 100644 --- a/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts +++ b/apps/tradinggoose/app/api/yjs/sessions/[sessionId]/snapshot/route.ts @@ -5,51 +5,48 @@ import { parseYjsTransportEnvelope, } from '@/lib/copilot/review-sessions/identity' import { verifyReviewTargetAccess } from '@/lib/copilot/review-sessions/permissions' +import { readBootstrappedReviewTargetSnapshot } from '@/lib/yjs/server/bootstrap-review-target' import { - readBootstrappedReviewTargetSnapshot, - ReviewTargetBootstrapError, -} from '@/lib/yjs/server/bootstrap-review-target' + applyYjsUpdateInSocketServer, + SocketServerBridgeError, +} from '@/lib/yjs/server/snapshot-bridge' export const dynamic = 'force-dynamic' -export async function GET( +function getPublicBridgeStatus(error: SocketServerBridgeError) { + const { status } = error + return status === 400 || status === 404 || status === 409 || status === 410 ? status : 503 +} + +async function authorizeYjsSnapshotRequest( request: NextRequest, - { params }: { params: Promise<{ sessionId: string }> } + sessionId: string, + accessMode: 'read' | 'write' ) { const session = await getSession() if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = session.user.id - const { sessionId } = await params - - const queryParams: Record = {} - request.nextUrl.searchParams.forEach((value, key) => { - queryParams[key] = value - }) - const accessMode = request.nextUrl.searchParams.get('accessMode') - if (accessMode !== 'read' && accessMode !== 'write') { - return NextResponse.json({ error: 'Invalid access mode' }, { status: 400 }) + return { response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } } let descriptor try { - const envelope = parseYjsTransportEnvelope(queryParams) + const envelope = parseYjsTransportEnvelope(Object.fromEntries(request.nextUrl.searchParams)) descriptor = buildReviewTargetDescriptorFromEnvelope(envelope) } catch (error) { - return NextResponse.json( - { error: error instanceof Error ? error.message : 'Invalid transport envelope' }, - { status: 400 } - ) + return { + response: NextResponse.json( + { error: error instanceof Error ? error.message : 'Invalid transport envelope' }, + { status: 400 } + ), + } } if (descriptor.yjsSessionId !== sessionId) { - return NextResponse.json({ error: 'Session ID mismatch' }, { status: 409 }) + return { response: NextResponse.json({ error: 'Session ID mismatch' }, { status: 409 }) } } const access = await verifyReviewTargetAccess( - userId, + session.user.id, { entityKind: descriptor.entityKind, entityId: descriptor.entityId, @@ -62,24 +59,77 @@ export async function GET( ) if (!access.hasAccess) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + return { response: NextResponse.json({ error: 'Forbidden' }, { status: 403 }) } } - const authorizedDescriptor = { - ...descriptor, - workspaceId: access.workspaceId ?? descriptor.workspaceId, + return { + descriptor: { + ...descriptor, + workspaceId: access.workspaceId ?? descriptor.workspaceId, + }, } +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ sessionId: string }> } +) { + const { sessionId } = await params + const accessMode = request.nextUrl.searchParams.get('accessMode') + if (accessMode !== 'read' && accessMode !== 'write') { + return NextResponse.json({ error: 'Invalid access mode' }, { status: 400 }) + } + + const authorized = await authorizeYjsSnapshotRequest(request, sessionId, accessMode) + if ('response' in authorized) return authorized.response try { - const snapshot = await readBootstrappedReviewTargetSnapshot(authorizedDescriptor) + const snapshot = await readBootstrappedReviewTargetSnapshot(authorized.descriptor) return NextResponse.json(snapshot, { status: snapshot.runtime.docState === 'expired' ? 410 : 200, }) } catch (error) { - if (error instanceof ReviewTargetBootstrapError) { - return NextResponse.json({ error: error.message }, { status: error.status }) + if (error instanceof SocketServerBridgeError) { + return NextResponse.json({ error: error.message }, { status: getPublicBridgeStatus(error) }) } return NextResponse.json({ error: 'Failed to load snapshot' }, { status: 500 }) } } + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ sessionId: string }> } +) { + const { sessionId } = await params + const authorized = await authorizeYjsSnapshotRequest(request, sessionId, 'write') + if ('response' in authorized) return authorized.response + + const { descriptor } = authorized + if (descriptor.entityKind === 'workflow' || !descriptor.entityId || !descriptor.workspaceId) { + return NextResponse.json({ error: 'Saved entity Yjs session required' }, { status: 400 }) + } + + try { + const { updateBase64 } = (await request.json().catch(() => ({}))) as { + updateBase64?: unknown + } + if (typeof updateBase64 !== 'string' || !updateBase64) { + return NextResponse.json({ error: 'updateBase64 is required' }, { status: 400 }) + } + + await applyYjsUpdateInSocketServer( + descriptor.yjsSessionId, + request.nextUrl.search, + updateBase64 + ) + + return NextResponse.json({ success: true }) + } catch (error) { + if (error instanceof SocketServerBridgeError) { + return NextResponse.json({ error: error.message }, { status: getPublicBridgeStatus(error) }) + } + + return NextResponse.json({ error: 'Failed to save Yjs session' }, { status: 500 }) + } +} diff --git a/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts new file mode 100644 index 000000000..e0216da45 --- /dev/null +++ b/apps/tradinggoose/app/mcp/[[...command]]/route.test.ts @@ -0,0 +1,183 @@ +/** + * @vitest-environment node + */ + +import { spawnSync } from 'child_process' +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { buildMcpInstallScript } from '../../../lib/mcp/install-script' + +async function callInstaller( + pathname: string, + command?: string[], + headers?: HeadersInit, + origin = 'https://studio.example.test' +) { + const { GET } = await import('./route') + return GET(new NextRequest(`${origin}${pathname}`, { headers }), { + params: Promise.resolve({ command }), + }) +} + +function expectShellScript(script: string) { + const shellCheck = spawnSync('sh', ['-n', '-c', script], { + encoding: 'utf8', + timeout: 5000, + }) + expect(shellCheck.status).toBe(0) + expect(shellCheck.stderr).toBe('') +} + +describe('MCP install route', () => { + beforeEach(() => { + vi.stubEnv('NEXT_PUBLIC_APP_URL', 'https://studio.example.test') + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('serves the default setup script at /mcp', async () => { + const response = await callInstaller('/mcp') + const script = await response.text() + + expectShellScript(script) + expect(response.headers.get('Content-Type')).toBe('text/x-shellscript; charset=utf-8') + expect(script).toContain("BASE_URL='https://studio.example.test'") + expect(script).toContain('COMMAND="setup"') + expect(script).toContain('TARGETS=""') + expect(script).toContain('curl -fsSL /mcp/setup | sh') + expect(script).toContain('curl -fsSL /mcp/setup/codex | sh') + expect(script).toContain('curl -fsSL /mcp/login | sh') + expect(script).toContain('irm /mcp/setup | iex') + expect(script).toContain('irm /mcp/setup/codex | iex') + expect(script).toContain('irm /mcp/login | iex') + expect(script).toContain("baseUrl + '/api/auth/mcp/start'") + expect(script).toContain("baseUrl + '/api/auth/mcp/poll'") + expect(script).toContain('const verificationKey = String(startJson?.verificationKey ||') + expect(script).toContain('return { code, verificationKey, token }') + expect(script).toContain('async function acknowledge(login)') + expect(script).toContain('ackApiKey: login.token') + expect(script).not.toContain('confirmLogin') + expect(script).not.toContain('confirm: true') + expect(script).toContain("baseUrl + '/api/copilot/mcp'") + expect(script).not.toContain("method: 'ping'") + expect(script).not.toContain('async function isTokenValid(token)') + expect(script).not.toContain('async function resolveAuthToken()') + expect(script).toContain("Authorization: Bearer ' + login.token") + expect(script).toContain('setup Write MCP config, authenticating when needed.') + expect(script).not.toContain('read-tokens') + expect(script).toContain('node - "$BASE_URL" "$COMMAND" "$TARGETS"') + expect(script).toContain('runConfigWriter([target, mcpUrl, login.token])') + expect(script).toContain("const mcpServerName = 'TradingGoose'") + expect(script).toContain("'[mcp_servers.' + mcpServerName + '.http_headers]'") + expect(script).toContain("'Authorization = ' + JSON.stringify('Bearer ' + token)") + expect(script).not.toContain('TRADINGGOOSE_BEARER_TOKEN') + expect(script).not.toContain('bearer_token_env_var') + expect(script).not.toContain("spawnSync('setx'") + expect(script).toContain("path.join(os.homedir(), '.codex', 'config.toml')") + expect(script).toContain("path.join(os.homedir(), '.cursor', 'mcp.json')") + expect(script).toContain("path.join(os.homedir(), '.claude.json')") + expect(script).toContain("path.join(os.homedir(), '.config', 'opencode', 'opencode.json')") + expect(script).not.toContain('workspaceId') + expect(script).not.toContain('entityId') + + const printedTokenIndex = script.indexOf("console.log('Authorization: Bearer ' + login.token)") + const firstReturnTokenIndex = script.indexOf('return { code, verificationKey, token }') + const setupIndex = script.indexOf("if (command === 'setup')") + const configWriteIndex = script.indexOf( + 'const configPath = runConfigWriter([target, mcpUrl, login.token])' + ) + const acknowledgeIndex = script.indexOf('await acknowledge(login)', setupIndex) + expect(printedTokenIndex).toBeGreaterThan(firstReturnTokenIndex) + expect(configWriteIndex).toBeGreaterThan(setupIndex) + expect(acknowledgeIndex).toBeGreaterThan(setupIndex) + expect(acknowledgeIndex).toBeLessThan(configWriteIndex) + }) + + it('serves target-specific setup scripts from the URL path', async () => { + const response = await callInstaller('/mcp/setup/codex', ['setup', 'codex']) + const script = await response.text() + + expectShellScript(script) + expect(script).toContain('COMMAND="setup"') + expect(script).toContain('TARGETS="codex"') + }) + + it('uses configured and quoted installer base URLs', async () => { + const response = await callInstaller( + '/mcp', + undefined, + undefined, + 'https://request.example.test' + ) + const script = await response.text() + + expect(script).toContain("BASE_URL='https://studio.example.test'") + expect(script).not.toContain("BASE_URL='https://request.example.test'") + + const shellScript = buildMcpInstallScript( + "https://studio.example.test/$(touch pwn)`bad`'quote", + { + command: 'login', + format: 'sh', + } + ) + const powerShellScript = buildMcpInstallScript( + "https://studio.example.test/$(bad)`bad`'quote", + { + command: 'login', + format: 'powershell', + } + ) + + expectShellScript(shellScript) + expect(shellScript).toContain( + "BASE_URL='https://studio.example.test/$(touch pwn)`bad`'\"'\"'quote'" + ) + expect(powerShellScript).toContain( + "$BaseUrl = 'https://studio.example.test/$(bad)`bad`''quote'" + ) + }) + + it('serves PowerShell scripts for PowerShell clients', async () => { + const response = await callInstaller('/mcp/setup/codex', ['setup', 'codex'], { + 'user-agent': 'Mozilla/5.0 PowerShell/7.5', + }) + const script = await response.text() + + expect(response.headers.get('Content-Type')).toBe('text/x-powershell; charset=utf-8') + expect(script).toContain("$BaseUrl = 'https://studio.example.test'") + expect(script).toContain("$Command = 'setup'") + expect(script).toContain("$Targets = @('codex')") + expect(script).toContain('irm /mcp/setup | iex') + expect(script).toContain("$NodeScript | & node - $BaseUrl $Command ($Targets -join ' ')") + expect(script).toContain("baseUrl + '/api/auth/mcp/start'") + expect(script).toContain('ackApiKey: login.token') + expect(script).not.toContain("runConfigWriter(['read-tokens'])") + expect(script).not.toContain("method: 'ping'") + expect(script).toContain("const mcpServerName = 'TradingGoose'") + expect(script).toContain("'[mcp_servers.' + mcpServerName + '.http_headers]'") + expect(script).toContain("'Authorization = ' + JSON.stringify('Bearer ' + token)") + expect(script).not.toContain('TRADINGGOOSE_BEARER_TOKEN') + expect(script).not.toContain('bearer_token_env_var') + expect(script).not.toContain("spawnSync('setx'") + expect(script).not.toContain('#!/bin/sh') + }) + + it('serves login scripts from the URL path', async () => { + const response = await callInstaller('/mcp/login', ['login']) + const script = await response.text() + + expectShellScript(script) + expect(script).toContain('COMMAND="login"') + expect(script).toContain('TARGETS=""') + }) + + it('rejects unknown installer commands', async () => { + const response = await callInstaller('/mcp/authorize', ['authorize']) + + expect(response.status).toBe(404) + await expect(response.text()).resolves.toBe('Unknown MCP installer command\n') + }) +}) diff --git a/apps/tradinggoose/app/mcp/[[...command]]/route.ts b/apps/tradinggoose/app/mcp/[[...command]]/route.ts new file mode 100644 index 000000000..8bd242b79 --- /dev/null +++ b/apps/tradinggoose/app/mcp/[[...command]]/route.ts @@ -0,0 +1,71 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { + buildMcpInstallScript, + type McpInstallScriptFormat, + type McpInstallScriptOptions, +} from '../../../lib/mcp/install-script' +import { getBaseUrl } from '../../../lib/urls/utils' + +export const dynamic = 'force-dynamic' + +const SETUP_TARGETS = new Set(['codex', 'cursor', 'claude', 'opencode', 'all']) + +function parseInstallOptions(command: string[] | undefined): McpInstallScriptOptions | null { + if (!command || command.length === 0) { + return { command: 'setup' } + } + + if (command.length === 1 && command[0] === 'login') { + return { command: 'login' } + } + + if (command[0] === 'setup') { + if (command.length === 1) { + return { command: 'setup' } + } + + const target = command[1] + if (command.length === 2 && SETUP_TARGETS.has(target)) { + return { + command: 'setup', + target: target as McpInstallScriptOptions['target'], + } + } + } + + return null +} + +function resolveScriptFormat(request: NextRequest): McpInstallScriptFormat { + const userAgent = request.headers.get('user-agent') ?? '' + return /\b(?:PowerShell|WindowsPowerShell|pwsh)\b/i.test(userAgent) ? 'powershell' : 'sh' +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ command?: string[] }> } +) { + const options = parseInstallOptions((await params).command) + if (!options) { + return new NextResponse('Unknown MCP installer command\n', { + status: 404, + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'X-Content-Type-Options': 'nosniff', + }, + }) + } + + const format = resolveScriptFormat(request) + + return new NextResponse(buildMcpInstallScript(getBaseUrl(), { ...options, format }), { + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': + format === 'powershell' + ? 'text/x-powershell; charset=utf-8' + : 'text/x-shellscript; charset=utf-8', + 'X-Content-Type-Options': 'nosniff', + }, + }) +} diff --git a/apps/tradinggoose/app/query-provider.tsx b/apps/tradinggoose/app/query-provider.tsx index 1d73c209e..de1745bd8 100644 --- a/apps/tradinggoose/app/query-provider.tsx +++ b/apps/tradinggoose/app/query-provider.tsx @@ -4,17 +4,28 @@ import type { ReactNode } from 'react' import { useState } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -export function QueryProvider({ children }: { children: ReactNode }) { - const [queryClient] = useState( - () => - new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - }, - }, - }) - ) +let browserQueryClient: QueryClient | undefined + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + }, + }, + }) +} + +export function getQueryClient() { + if (typeof window === 'undefined') { + return createQueryClient() + } + browserQueryClient ??= createQueryClient() + return browserQueryClient +} + +export function QueryProvider({ children }: { children: ReactNode }) { + const [queryClient] = useState(getQueryClient) return {children} } diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/api-keys/workspace-api-keys-card.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/api-keys/workspace-api-keys-card.tsx index a9e1283c5..2aefb2e45 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/api-keys/workspace-api-keys-card.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/api-keys/workspace-api-keys-card.tsx @@ -2,33 +2,17 @@ import { forwardRef, + type Ref, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, - type Ref, } from 'react' +import { AlertCircle, Check, Copy, Pencil, Plus, Search, Trash2, X } from 'lucide-react' import { useLocale, useTranslations } from 'next-intl' -import { - useApiKeys, - useCreateApiKey, - useDeleteApiKey, - type ApiKey, -} from '@/hooks/queries/api-keys' -import { - AlertCircle, - Check, - Copy, - Eye, - EyeOff, - Pencil, - Plus, - Search, - Trash2, - X, -} from 'lucide-react' +import { Alert, AlertDescription, Button, Input, Label, Skeleton } from '@/components/ui' import { AlertDialog, AlertDialogAction, @@ -39,11 +23,11 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog' -import { Alert, AlertDescription, Button, Input, Label, Skeleton } from '@/components/ui' import { createLogger } from '@/lib/logs/console/logger' -import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { cn } from '@/lib/utils' -import { type LocaleCode } from '@/i18n/utils' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { type ApiKey, useApiKeys, useCreateApiKey, useDeleteApiKey } from '@/hooks/queries/api-keys' +import type { LocaleCode } from '@/i18n/utils' interface WorkspaceApiKeysCardProps { workspaceId?: string @@ -61,25 +45,6 @@ export interface WorkspaceApiKeysCardHandle { openCreateDialog: () => void } -const getMaskedKeyValue = (apiKey: ApiKey): string => { - const sourceKey = apiKey.key || apiKey.displayKey || '' - if (!sourceKey) return '' - - const prefixLength = Math.min(4, sourceKey.length) - const suffixLength = Math.min(4, sourceKey.length - prefixLength) - const prefix = sourceKey.slice(0, prefixLength) - const suffix = sourceKey.slice(sourceKey.length - suffixLength) - - const totalLength = apiKey.key?.length ?? sourceKey.length - const maskedSegmentLength = Math.max(totalLength - (prefixLength + suffixLength), 3) - - if (maskedSegmentLength <= 0) { - return `${prefix}${suffix}` - } - - return `${prefix}${'.'.repeat(maskedSegmentLength)}${suffix}` -} - function ApiKeyDisplay({ value }: { value: string }) { return (
@@ -122,8 +87,6 @@ const WorkspaceApiKeysCardComponent = ( const [deleteConfirmationName, setDeleteConfirmationName] = useState('') const [copySuccess, setCopySuccess] = useState(false) const [createError, setCreateError] = useState(null) - const [revealedKeys, setRevealedKeys] = useState>({}) - const [copiedKeyId, setCopiedKeyId] = useState(null) const copyTimeoutRef = useRef | null>(null) const [editingKeyId, setEditingKeyId] = useState(null) const [editingKeyName, setEditingKeyName] = useState('') @@ -209,35 +172,6 @@ const WorkspaceApiKeysCardComponent = ( }) } - const handleCopyKey = useCallback( - (keyValue: string, keyId: string) => { - if (!keyValue || typeof navigator === 'undefined' || !navigator.clipboard) { - return - } - - void navigator.clipboard - .writeText(keyValue) - .then(() => { - setCopiedKeyId(keyId) - if (copyTimeoutRef.current) { - clearTimeout(copyTimeoutRef.current) - } - copyTimeoutRef.current = setTimeout(() => setCopiedKeyId(null), 1500) - }) - .catch((error) => { - logger.error('Error copying API key', { error, scope }) - }) - }, - [] - ) - - const toggleRevealKey = useCallback((keyId: string) => { - setRevealedKeys((prev) => ({ - ...prev, - [keyId]: !prev[keyId], - })) - }, []) - const startEditingKey = useCallback( (key: ApiKey) => { if (!canRenameKeys) return @@ -299,7 +233,18 @@ const WorkspaceApiKeysCardComponent = ( } finally { setIsUpdatingKeyName(false) } - }, [cancelEditingKey, canRenameKeys, editingKeyId, editingKeyName, refetchApiKeys, scope, scopeLabel, t, workspaceId, isWorkspaceScope]) + }, [ + cancelEditingKey, + canRenameKeys, + editingKeyId, + editingKeyName, + refetchApiKeys, + scope, + scopeLabel, + t, + workspaceId, + isWorkspaceScope, + ]) const handleCreateKey = async () => { if (!newKeyName.trim() || isSubmittingCreate) return @@ -327,9 +272,7 @@ const WorkspaceApiKeysCardComponent = ( } catch (error) { logger.error('Error creating API key', { error, scope }) const message = - error instanceof Error - ? error.message - : t('labels.failedCreate', { scope: scopeLabel }) + error instanceof Error ? error.message : t('labels.failedCreate', { scope: scopeLabel }) setCreateError(message) } } @@ -356,9 +299,22 @@ const WorkspaceApiKeysCardComponent = ( } const copyToClipboard = (key: string) => { - navigator.clipboard.writeText(key) - setCopySuccess(true) - setTimeout(() => setCopySuccess(false), 1500) + if (typeof navigator === 'undefined' || !navigator.clipboard) { + return + } + + void navigator.clipboard + .writeText(key) + .then(() => { + setCopySuccess(true) + if (copyTimeoutRef.current) { + clearTimeout(copyTimeoutRef.current) + } + copyTimeoutRef.current = setTimeout(() => setCopySuccess(false), 1500) + }) + .catch((error) => { + logger.error('Error copying API key', { error, scope }) + }) } const renderCardView = () => { @@ -403,16 +359,6 @@ const WorkspaceApiKeysCardComponent = ( return (
{filteredKeys.map((key) => { - const rawKeyValue = key.key || key.displayKey || '' - const isRevealed = Boolean(revealedKeys[key.id]) - const displayValue = rawKeyValue - ? isRevealed - ? rawKeyValue - : getMaskedKeyValue(key) - : key.displayKey || '—' - const canRevealOrCopy = Boolean(rawKeyValue) - const isCopied = copiedKeyId === key.id - return (
{canRenameKeys && editingKeyId === key.id ? (
-
+
{ if (editingKeyId === key.id) { @@ -442,7 +388,7 @@ const WorkspaceApiKeysCardComponent = ( } }} disabled={isUpdatingKeyName} - className='h-8 flex-1 min-w-0' + className='h-8 min-w-0 flex-1' autoComplete='off' />
- {renameError && ( -

{renameError}

- )} + {renameError &&

{renameError}

}
) : (
@@ -475,7 +419,9 @@ const WorkspaceApiKeysCardComponent = ( disabled={isUpdatingKeyName || (isWorkspaceScope && !workspaceId)} > - {t('labels.rename', { scope: scopeLabel })} + + {t('labels.rename', { scope: scopeLabel })} + )}
@@ -483,39 +429,9 @@ const WorkspaceApiKeysCardComponent = (
-
- +
- )} @@ -600,23 +516,14 @@ const WorkspaceApiKeysCardComponent = ( } return filteredKeys.map((key) => { - const rawKeyValue = key.key || key.displayKey || '' - const isRevealed = Boolean(revealedKeys[key.id]) - const displayValue = rawKeyValue - ? isRevealed - ? rawKeyValue - : getMaskedKeyValue(key) - : key.displayKey || '—' - const canRevealOrCopy = Boolean(rawKeyValue) - const isCopied = copiedKeyId === key.id const isEditing = canRenameKeys && editingKeyId === key.id return ( - + {formatDate(key.createdAt)} - + {canRenameKeys && editingKeyId === key.id ? (
@@ -646,58 +553,24 @@ const WorkspaceApiKeysCardComponent = (

{renameError}

)}
- ) : ( -
-

{key.name}

-
- )} + ) : ( +
+

{key.name}

+
+ )}
-
- +
-
- + {formatDate(key.lastUsed)} -
+
{isEditing ? ( <>
@@ -960,21 +831,22 @@ const WorkspaceApiKeysCardComponent = ( {t('dialogs.newKeyTitle', { scope: scopeLabel })} - - {t('dialogs.newKeyDescription')} - + {t('dialogs.newKeyDescription')} {newKey && (
- {newKey.key} + {newKey.key || '—'}
@@ -987,16 +859,12 @@ const WorkspaceApiKeysCardComponent = ( {t('dialogs.deleteTitle', { scope: scopeLabel })} - - {t('dialogs.deleteDescription')} - + {t('dialogs.deleteDescription')} {deleteKey && (
-

- {t('dialogs.deletePrompt', { name: deleteKey.name })} -

+

{t('dialogs.deletePrompt', { name: deleteKey.name })}

- - {docCount} {docCount === 1 ? t('docsSingular') : t('docsPlural')} - -
{id?.slice(0, 8)}
- {/* Timestamps */} - {(createdAt || updatedAt) && ( -
- {updatedAt && ( - - {t('updated')} {formatRelativeTime(updatedAt, locale)} - - )} - {updatedAt && createdAt && } - {createdAt && ( - - {t('created')} {formatRelativeTime(createdAt, locale)} - - )} -
- )} -

{description}

@@ -240,13 +163,7 @@ export function BaseOverview({ {t('deleteTitle')} - - {t('deleteDescription', { - title, - count: docCount, - plural: docCount === 1 ? '' : 's', - })} - + {t('deleteDescription', { title })} {t('cancel')} diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/create-modal/create-modal.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/create-modal/create-modal.tsx index 9b0ce39e1..64a65f7e8 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/create-modal/create-modal.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/create-modal/create-modal.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { zodResolver } from '@hookform/resolvers/zod' import { AlertCircle, Check, Loader2, X } from 'lucide-react' import { useParams } from 'next/navigation' +import { useTranslations } from 'next-intl' import { useForm } from 'react-hook-form' import { z } from 'zod' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' @@ -22,7 +23,6 @@ import { import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components' import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload' import type { KnowledgeBaseData } from '@/stores/knowledge/store' -import { useTranslations } from 'next-intl' const logger = createLogger('CreateModal') @@ -317,8 +317,6 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea const newKnowledgeBase = result.data if (files.length > 0) { - newKnowledgeBase.docCount = files.length - if (onKnowledgeBaseCreated) { onKnowledgeBaseCreated(newKnowledgeBase) } @@ -524,13 +522,9 @@ export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: Crea isDragging ? 'text-amber-700' : '' }`} > - {isDragging - ? t('dropFilesHere') - : t('dropFilesHereOrClickToBrowse')} -

-

- {t('supportedFormats')} + {isDragging ? t('dropFilesHere') : t('dropFilesHereOrClickToBrowse')}

+

{t('supportedFormats')}

diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/shared.ts b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/shared.ts index e113d769f..1d135c238 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/shared.ts +++ b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/shared.ts @@ -6,15 +6,10 @@ export const dropdownContentClass = export const commandListClass = 'overflow-y-auto overflow-x-hidden' -export type SortOption = 'name' | 'createdAt' | 'updatedAt' | 'docCount' +export type SortOption = 'name' export type SortOrder = 'asc' | 'desc' export const SORT_OPTION_DEFINITIONS = [ - { value: 'updatedAt-desc', labelKey: 'sort.lastUpdated' }, - { value: 'createdAt-desc', labelKey: 'sort.newestFirst' }, - { value: 'createdAt-asc', labelKey: 'sort.oldestFirst' }, { value: 'name-asc', labelKey: 'sort.nameAsc' }, { value: 'name-desc', labelKey: 'sort.nameDesc' }, - { value: 'docCount-desc', labelKey: 'sort.mostDocuments' }, - { value: 'docCount-asc', labelKey: 'sort.leastDocuments' }, ] as const diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 633ea290b..90ce47ca9 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -26,7 +26,6 @@ import { dropdownContentClass, filterButtonClass, SORT_OPTION_DEFINITIONS, - type SortOption, type SortOrder, } from '@/app/workspace/[workspaceId]/knowledge/components/shared' import { @@ -38,10 +37,6 @@ import { GlobalNavbarHeader } from '@/global-navbar' import { useKnowledgeBasesList } from '@/hooks/use-knowledge' import type { KnowledgeBaseData } from '@/stores/knowledge/store' -interface KnowledgeBaseWithDocCount extends KnowledgeBaseData { - docCount?: number -} - export function Knowledge() { const params = useParams() const workspaceId = params.workspaceId as string @@ -54,10 +49,9 @@ export function Knowledge() { const [searchQuery, setSearchQuery] = useState('') const [isCreateModalOpen, setIsCreateModalOpen] = useState(false) - const [sortBy, setSortBy] = useState('updatedAt') - const [sortOrder, setSortOrder] = useState('desc') + const [sortOrder, setSortOrder] = useState('asc') - const currentSortValue = `${sortBy}-${sortOrder}` + const currentSortValue = `name-${sortOrder}` const sortOptions = useMemo( () => SORT_OPTION_DEFINITIONS.map((option) => ({ @@ -67,11 +61,10 @@ export function Knowledge() { [t] ) const currentSortLabel = - sortOptions.find((opt) => opt.value === currentSortValue)?.label || t('sort.lastUpdated') + sortOptions.find((opt) => opt.value === currentSortValue)?.label || t('sort.nameAsc') const handleSortChange = (value: string) => { - const [field, order] = value.split('-') as [SortOption, SortOrder] - setSortBy(field) + const [, order] = value.split('-') as ['name', SortOrder] setSortOrder(order) } @@ -85,16 +78,13 @@ export function Knowledge() { const filteredAndSortedKnowledgeBases = useMemo(() => { const filtered = filterKnowledgeBases(knowledgeBases, searchQuery) - return sortKnowledgeBases(filtered, sortBy, sortOrder) - }, [knowledgeBases, searchQuery, sortBy, sortOrder]) + return sortKnowledgeBases(filtered, sortOrder) + }, [knowledgeBases, searchQuery, sortOrder]) - const formatKnowledgeBaseForDisplay = (kb: KnowledgeBaseWithDocCount) => ({ + const formatKnowledgeBaseForDisplay = (kb: KnowledgeBaseData) => ({ id: kb.id, title: kb.name, - docCount: kb.docCount || 0, description: kb.description || t('defaults.noDescriptionProvided'), - createdAt: kb.createdAt, - updatedAt: kb.updatedAt, }) const headerLeftContent = ( @@ -132,7 +122,7 @@ export function Knowledge() { >
{sortOptions.map((option, index) => ( -
+
handleSortChange(option.value)} className='flex cursor-pointer items-center justify-between rounded-md px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50' @@ -177,9 +167,7 @@ export function Knowledge() { {/* Error State */} {error && (
-

- {t('errors.load', { error })} -

+

{t('errors.load', { error })}

- - - {tooltipText} - + emitIndicatorEditorAction({ action: 'export', panelId, widgetKey })} + /> ) } @@ -197,33 +119,15 @@ export function IndicatorEditorSaveButton({ : (indicatorId ?? null) const saveDisabled = !workspaceId || !resolvedIndicatorId - const handleSave = () => { - emitIndicatorEditorAction({ - action: 'save', - panelId, - widgetKey, - }) - } - return ( - - - - - - - {copy.saveIndicator} - + emitIndicatorEditorAction({ action: 'save', panelId, widgetKey })} + /> ) } @@ -245,32 +149,14 @@ export function IndicatorEditorVerifyButton({ : (indicatorId ?? null) const verifyDisabled = !workspaceId || !resolvedIndicatorId - const handleVerify = () => { - emitIndicatorEditorAction({ - action: 'verify', - panelId, - widgetKey, - }) - } - return ( - - - - - - - Verify indicator - + emitIndicatorEditorAction({ action: 'verify', panelId, widgetKey })} + /> ) } diff --git a/apps/tradinggoose/widgets/widgets/editor_indicator/components/pine-indicator-code-panel.tsx b/apps/tradinggoose/widgets/widgets/editor_indicator/components/pine-indicator-code-panel.tsx index 172c6316f..b786a5432 100644 --- a/apps/tradinggoose/widgets/widgets/editor_indicator/components/pine-indicator-code-panel.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_indicator/components/pine-indicator-code-panel.tsx @@ -1,6 +1,7 @@ 'use client' import { type MutableRefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type * as Y from 'yjs' import { buildMonacoIndicatorDiagnosticSource, type MonacoEditorHandle, @@ -16,16 +17,15 @@ import { } from '@/components/ui/select' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { executeBrowserPineIndicator } from '@/lib/indicators/browser-execution' +import { exportIndicatorsAsJson } from '@/lib/indicators/import-export' import { buildInputsMapFromMeta, inferInputMetaFromPineCode } from '@/lib/indicators/input-meta' import { PINE_CHEAT_SHEET_EXTRA_LIBS } from '@/lib/indicators/pine-cheat-sheet' import { mapMarketSeriesToBarsMs } from '@/lib/indicators/series-data' import { detectTriggerUsage } from '@/lib/indicators/trigger-detection' import { detectUnsupportedFeatures } from '@/lib/indicators/unsupported' import { generateMockMarketSeries } from '@/lib/market/mock-series' -import { useUpdateIndicator } from '@/hooks/queries/indicators' +import { useYjsStringField } from '@/lib/yjs/use-entity-fields' import { useWand } from '@/hooks/workflow/use-wand' -import type { IndicatorDefinition } from '@/stores/indicators/types' -import { emitIndicatorEditorState } from '@/widgets/utils/indicator-editor-actions' import { CHEAT_SHEET_GROUPS, type CheatSheetGroup, @@ -34,13 +34,13 @@ import { WandPromptBar } from '@/widgets/widgets/editor_workflow/components/wand import { CodeEditor } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/tool-input/components/code-editor/code-editor' type IndicatorCodePanelProps = { - indicator: IndicatorDefinition indicatorId: string workspaceId: string + doc: Y.Doc | null + save: () => Promise + exportRef: MutableRefObject<() => void> saveRef: MutableRefObject<() => void> verifyRef: MutableRefObject<() => void> - panelId?: string - widgetKey?: string } const PINE_WAND_PROMPT = `# Role @@ -159,17 +159,16 @@ const verifyIndicatorInBrowser = async ({ } export function IndicatorCodePanel({ - indicator, indicatorId, workspaceId, + doc, + save, + exportRef, saveRef, verifyRef, - panelId, - widgetKey, }: IndicatorCodePanelProps) { - const updateMutation = useUpdateIndicator() - - const [pineCode, setPineCode] = useState('') + const [indicatorName] = useYjsStringField(doc, 'name') + const [pineCode, setPineCode] = useYjsStringField(doc, 'pineCode') const [verifyStatus, setVerifyStatus] = useState< | { state: 'idle' } @@ -178,6 +177,7 @@ export function IndicatorCodePanel({ | { state: 'warning'; message: string; warnings: string[] } | { state: 'error'; message: string } >({ state: 'idle' }) + const [saveError, setSaveError] = useState(null) const [showEnvVars, setShowEnvVars] = useState(false) const [envVarSearchTerm, setEnvVarSearchTerm] = useState('') @@ -187,7 +187,6 @@ export function IndicatorCodePanel({ const codeEditorRef = useRef(null) const codeEditorHandleRef = useRef(null) - const indicatorSignatureRef = useRef('') const disallowedGlobalMessage = 'Do not use $.pine or $.data. Use globals directly (ta, input, plot, open, high, low, close, volume).' const monacoModelPath = useMemo( @@ -217,22 +216,9 @@ export function IndicatorCodePanel({ }) useEffect(() => { - if (!indicator) return - const signature = `${indicator.id}:${indicator.updatedAt ?? indicator.createdAt ?? ''}` - if (indicatorSignatureRef.current === signature) return - indicatorSignatureRef.current = signature - - setPineCode(indicator.pineCode ?? '') setVerifyStatus({ state: 'idle' }) - }, [indicator]) - - useEffect(() => { - emitIndicatorEditorState({ - isDirty: pineCode !== (indicator.pineCode ?? ''), - panelId, - widgetKey, - }) - }, [indicator.id, indicator.pineCode, panelId, pineCode, widgetKey]) + setSaveError(null) + }, [doc, indicatorId]) const updateCursorState = ( value: string, @@ -274,27 +260,60 @@ export function IndicatorCodePanel({ } const handleSave = useCallback(async () => { - if (!workspaceId || !indicatorId) return - const disallowedMessage = validateNoDollarGlobals(pineCode) + if (!workspaceId || !indicatorId || !doc) return + const currentPineCode = codeEditorHandleRef.current?.getEditor()?.getValue() ?? pineCode + const disallowedMessage = validateNoDollarGlobals(currentPineCode) if (disallowedMessage) { + setSaveError(null) setVerifyStatus({ state: 'error', message: disallowedMessage }) return } - const inferredInputMeta = inferInputMetaFromPineCode(pineCode) + + setSaveError(null) try { - await updateMutation.mutateAsync({ - workspaceId, - indicatorId, - updates: { - pineCode, - inputMeta: inferredInputMeta ?? null, - }, - }) + if (currentPineCode !== pineCode) { + setPineCode(currentPineCode) + } + + await save() } catch (err) { + setSaveError(err instanceof Error ? err.message : 'Failed to save indicator.') console.error('Failed to update indicator', err) } - }, [workspaceId, indicatorId, updateMutation, pineCode]) + }, [workspaceId, indicatorId, doc, pineCode, save, setPineCode]) + + const handleExport = useCallback(() => { + if (!doc) return + const json = exportIndicatorsAsJson({ + exportedFrom: 'indicatorEditor', + indicators: [ + { + name: indicatorName, + pineCode, + }, + ], + }) + const fileNameBase = + indicatorName + .trim() + .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-') + .replace(/\s+/g, '-') || 'indicator' + const blobUrl = URL.createObjectURL( + new Blob([json], { type: 'application/json;charset=utf-8' }) + ) + const link = document.createElement('a') + link.href = blobUrl + link.download = `${fileNameBase}.json` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(blobUrl) + }, [doc, indicatorName, pineCode]) + + useEffect(() => { + exportRef.current = handleExport + }, [exportRef, handleExport]) const handleVerify = useCallback(async () => { if (!workspaceId) return @@ -444,6 +463,11 @@ export function IndicatorCodePanel({ )} )} + {saveError ? ( + + {saveError} + + ) : null}
diff --git a/apps/tradinggoose/widgets/widgets/editor_indicator/editor-indicator-body.tsx b/apps/tradinggoose/widgets/widgets/editor_indicator/editor-indicator-body.tsx index 3c10d570e..e27cae7cf 100644 --- a/apps/tradinggoose/widgets/widgets/editor_indicator/editor-indicator-body.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_indicator/editor-indicator-body.tsx @@ -1,9 +1,9 @@ 'use client' import { useCallback, useEffect, useRef } from 'react' -import { useLocale } from 'next-intl' -import { LoadingAgent } from '@/components/ui/loading-agent' import { useMessages } from 'next-intl' +import { LoadingAgent } from '@/components/ui/loading-agent' +import { useSavedEntityYjsSession } from '@/lib/yjs/use-entity-fields' import { useIndicators } from '@/hooks/queries/indicators' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' import type { PairColor } from '@/widgets/pair-colors' @@ -24,7 +24,6 @@ export function EditorIndicatorWidgetBody({ widget, onWidgetParamsChange, }: EditorIndicatorWidgetBodyProps) { - const locale = useLocale() const copy = useMessages().workspace.widgets.indicatorEditor.body const workspaceId = context?.workspaceId ?? null const { data: indicators = [], isLoading, error } = useIndicators(workspaceId ?? '') @@ -47,10 +46,13 @@ export function EditorIndicatorWidgetBody({ workspaceIndicators.some((indicator) => indicator.id === normalizedRequestedIndicatorId) const indicatorId = hasRequestedIndicator ? normalizedRequestedIndicatorId - : (isLinkedToColorPair ? null : (workspaceIndicators[0]?.id ?? null)) + : isLinkedToColorPair + ? null + : (workspaceIndicators[0]?.id ?? null) const indicator = indicatorId ? (workspaceIndicators.find((candidate) => candidate.id === indicatorId) ?? null) : null + const indicatorSession = useSavedEntityYjsSession('indicator', indicatorId, workspaceId) useEffect(() => { if (!indicatorId) { @@ -97,9 +99,14 @@ export function EditorIndicatorWidgetBody({ }, }) + const codeExportRef = useRef<() => void>(() => {}) const codeSaveRef = useRef<() => void>(() => {}) const codeVerifyRef = useRef<() => void>(() => {}) + const handleExport = useCallback(() => { + codeExportRef.current() + }, []) + const handleSave = useCallback(() => { codeSaveRef.current() }, []) @@ -111,6 +118,7 @@ export function EditorIndicatorWidgetBody({ useIndicatorEditorActions({ panelId, widget, + onExport: handleExport, onSave: handleSave, onVerify: handleVerify, }) @@ -153,16 +161,28 @@ export function EditorIndicatorWidgetBody({ return } + if (indicatorSession.error) { + return + } + + if (indicatorSession.isLoading) { + return ( +
+ +
+ ) + } + return (
) diff --git a/apps/tradinggoose/widgets/widgets/editor_indicator/index.test.tsx b/apps/tradinggoose/widgets/widgets/editor_indicator/index.test.tsx index 66a6d9e56..16ab83399 100644 --- a/apps/tradinggoose/widgets/widgets/editor_indicator/index.test.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_indicator/index.test.tsx @@ -6,8 +6,10 @@ import type { ReactNode } from 'react' import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useIndicatorsStore } from '@/stores/indicators/store' -import { emitIndicatorEditorState } from '@/widgets/utils/indicator-editor-actions' +import { + INDICATOR_EDITOR_ACTION_EVENT, + type IndicatorEditorActionEventDetail, +} from '@/widgets/events' import { editorIndicatorWidget } from '@/widgets/widgets/editor_indicator' vi.mock('@/components/ui/tooltip', () => ({ @@ -24,20 +26,9 @@ const reactActEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } -const readBlobText = async (blob: Blob) => - await new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => resolve(String(reader.result ?? '')) - reader.onerror = () => reject(reader.error) - reader.readAsText(blob) - }) - describe('Indicator Editor header controls', () => { let container: HTMLDivElement let root: Root - let createObjectUrlSpy: ReturnType - let revokeObjectUrlSpy: ReturnType - let clickSpy: ReturnType beforeEach(() => { vi.clearAllMocks() @@ -45,44 +36,6 @@ describe('Indicator Editor header controls', () => { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) - - useIndicatorsStore.getState().resetAll() - useIndicatorsStore.getState().setIndicators('workspace-1', [ - { - id: 'indicator-1', - workspaceId: 'workspace-1', - userId: 'user-1', - name: 'RSI Export Example', - color: '#3972F6', - pineCode: "indicator('RSI Export Example')", - inputMeta: { - Length: { - title: 'Length', - type: 'int', - defval: 14, - }, - }, - createdAt: '2026-04-08T15:30:00.000Z', - updatedAt: '2026-04-08T15:30:00.000Z', - }, - ]) - - createObjectUrlSpy = vi.fn(() => 'blob:indicator-export') - revokeObjectUrlSpy = vi.fn() - clickSpy = vi.fn() - - Object.defineProperty(globalThis.URL, 'createObjectURL', { - configurable: true, - value: createObjectUrlSpy, - }) - Object.defineProperty(globalThis.URL, 'revokeObjectURL', { - configurable: true, - value: revokeObjectUrlSpy, - }) - Object.defineProperty(HTMLAnchorElement.prototype, 'click', { - configurable: true, - value: clickSpy, - }) }) afterEach(() => { @@ -90,7 +43,6 @@ describe('Indicator Editor header controls', () => { root.unmount() }) container.remove() - useIndicatorsStore.getState().resetAll() }) it('renders Export indicator immediately left of Save indicator', async () => { @@ -132,7 +84,12 @@ describe('Indicator Editor header controls', () => { expect(buttons[1]?.hasAttribute('disabled')).toBe(true) }) - it('disables export while the editor is dirty and re-enables it when the editor becomes clean', async () => { + it('emits verify, export, and save actions for the selected indicator', async () => { + const actionSpy = vi.fn() + const handler = (event: Event) => { + actionSpy((event as CustomEvent).detail) + } + window.addEventListener(INDICATOR_EDITOR_ACTION_EVENT, handler) const header = editorIndicatorWidget.renderHeader?.({ context: { workspaceId: 'workspace-1' } as any, panelId: 'panel-1', @@ -148,90 +105,28 @@ describe('Indicator Editor header controls', () => { }) const buttons = Array.from(container.querySelectorAll('button')) - const exportButton = buttons[1] - - expect(exportButton?.hasAttribute('disabled')).toBe(true) await act(async () => { - emitIndicatorEditorState({ - isDirty: false, - panelId: 'panel-1', - widgetKey: 'editor_indicator', - }) + buttons[0]?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + buttons[1]?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + buttons[2]?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) - expect(exportButton?.hasAttribute('disabled')).toBe(false) - - await act(async () => { - emitIndicatorEditorState({ - isDirty: true, - panelId: 'panel-1', - widgetKey: 'editor_indicator', - }) - }) - - expect(exportButton?.hasAttribute('disabled')).toBe(true) - }) - - it('downloads the unified export envelope for the selected indicator', async () => { - const header = editorIndicatorWidget.renderHeader?.({ - context: { workspaceId: 'workspace-1' } as any, + expect(actionSpy).toHaveBeenNthCalledWith(1, { + action: 'verify', panelId: 'panel-1', - widget: { - key: 'editor_indicator', - params: { indicatorId: 'indicator-1' }, - pairColor: 'gray', - } as any, - } as any) - - await act(async () => { - root.render(header?.right as ReactNode) + widgetKey: 'editor_indicator', }) - - const buttons = Array.from(container.querySelectorAll('button')) - const exportButton = buttons[1] - - await act(async () => { - emitIndicatorEditorState({ - isDirty: false, - panelId: 'panel-1', - widgetKey: 'editor_indicator', - }) - }) - - await act(async () => { - exportButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + expect(actionSpy).toHaveBeenNthCalledWith(2, { + action: 'export', + panelId: 'panel-1', + widgetKey: 'editor_indicator', }) - - expect(createObjectUrlSpy).toHaveBeenCalledTimes(1) - expect(clickSpy).toHaveBeenCalledTimes(1) - expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:indicator-export') - - const blob = createObjectUrlSpy.mock.calls[0]?.[0] as Blob - const payload = JSON.parse(await readBlobText(blob)) - - expect(payload).toMatchObject({ - version: '1', - fileType: 'tradingGooseExport', - exportedFrom: 'indicatorEditor', - resourceTypes: ['indicators'], - skills: [], - workflows: [], - customTools: [], - watchlists: [], - indicators: [ - { - name: 'RSI Export Example', - pineCode: "indicator('RSI Export Example')", - inputMeta: { - Length: { - title: 'Length', - type: 'int', - defval: 14, - }, - }, - }, - ], + expect(actionSpy).toHaveBeenNthCalledWith(3, { + action: 'save', + panelId: 'panel-1', + widgetKey: 'editor_indicator', }) + window.removeEventListener(INDICATOR_EDITOR_ACTION_EVENT, handler) }) }) diff --git a/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx b/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx index eebb71cdd..774227d1b 100644 --- a/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx @@ -1,10 +1,15 @@ 'use client' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { type SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useMessages } from 'next-intl' +import type * as Y from 'yjs' import { LoadingAgent } from '@/components/ui/loading-agent' +import { sanitizeRecord } from '@/lib/utils' +import { getFieldsMap, setEntityField } from '@/lib/yjs/entity-session' +import { useSavedEntityYjsSession } from '@/lib/yjs/use-entity-fields' +import { useYjsSubscription } from '@/lib/yjs/use-yjs-subscription' import { useMcpServerTest } from '@/hooks/use-mcp-server-test' import { useMcpTools } from '@/hooks/use-mcp-tools' -import { useMessages } from 'next-intl' import { formatTemplate } from '@/i18n/utils' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' import { useMcpServersStore } from '@/stores/mcp-servers/store' @@ -16,8 +21,6 @@ import { useMcpSelectionPersistence } from '@/widgets/utils/mcp-selection' import { McpServerForm } from '@/widgets/widgets/_shared/mcp/components/mcp-server-form' import { createDefaultMcpServerFormData, - createFormDataFromServer, - createMcpSavePayload, type McpServerFormData, resolveMcpServerId, } from '@/widgets/widgets/_shared/mcp/utils' @@ -25,9 +28,6 @@ import { WidgetStateMessage } from '@/widgets/widgets/editor_indicator/component type EditorMcpWidgetBodyProps = WidgetComponentProps -const getServerName = (server?: Pick | null) => - server?.name?.trim() || '' - const formatRelativeTime = ( dateString: string | undefined, copy: { @@ -84,6 +84,52 @@ const getStatusLabel = ( return copy.disconnected } +function readMcpFormData(doc: Y.Doc | null, fallback: McpServerFormData): McpServerFormData { + if (!doc) return fallback + const fields = getFieldsMap(doc) + return { + name: fields.get('name') ?? fallback.name, + description: fields.get('description') ?? fallback.description, + transport: fields.get('transport') ?? fallback.transport, + url: fields.get('url') ?? fallback.url, + headers: fields.get('headers') ?? fallback.headers, + command: fields.get('command') ?? fallback.command, + args: fields.get('args') ?? fallback.args, + env: fields.get('env') ?? fallback.env, + timeout: fields.get('timeout') ?? fallback.timeout, + retries: fields.get('retries') ?? fallback.retries, + enabled: fields.get('enabled') ?? fallback.enabled, + } +} + +function useMcpServerYjsFormData( + doc: Y.Doc | null, + fallback: McpServerFormData +): [McpServerFormData, (next: SetStateAction) => void] { + const subscribe = useMemo(() => { + if (!doc) return (cb: () => void) => () => {} + const fields = getFieldsMap(doc) + return (cb: () => void) => { + fields.observe(cb) + return () => fields.unobserve(cb) + } + }, [doc]) + const read = useCallback(() => readMcpFormData(doc, fallback), [doc, fallback]) + const formData = useYjsSubscription(subscribe, read, fallback) + const setFormData = useCallback( + (next: SetStateAction) => { + if (!doc) return + const value = typeof next === 'function' ? next(formData) : next + for (const [key, fieldValue] of Object.entries(value)) { + setEntityField(doc, key, fieldValue) + } + }, + [doc, formData] + ) + + return [formData, setFormData] +} + const refreshServerApi = async ( serverId: string, workspaceId: string, @@ -118,26 +164,22 @@ export function EditorMcpWidgetBody({ const isLinkedToColorPair = resolvedPairColor !== 'gray' const pairContext = usePairColorContext(resolvedPairColor) const setPairContext = useSetPairColorContext() - const [formDataState, setFormDataState] = useState(() => - createDefaultMcpServerFormData() - ) const [saveError, setSaveError] = useState(null) const initialFormDataRef = useRef(createDefaultMcpServerFormData()) const initializedServerIdRef = useRef(null) + const defaultFormData = useMemo(() => createDefaultMcpServerFormData(), []) const { servers, isLoading: isServersLoading, error: serverError, fetchServers, refreshServer, - updateServer, } = useMcpServersStore((state) => ({ servers: state.servers, isLoading: state.isLoading, error: state.error, fetchServers: state.fetchServers, refreshServer: state.refreshServer, - updateServer: state.updateServer, })) const { refreshTools, getToolsByServer } = useMcpTools(workspaceId ?? '') const { testResult, isTestingConnection, testConnection, clearTestResult } = useMcpServerTest() @@ -152,11 +194,7 @@ export function EditorMcpWidgetBody({ workspaceId ? servers .filter((server) => server.workspaceId === workspaceId && !server.deletedAt) - .sort((a, b) => { - const aTime = Date.parse(a.updatedAt ?? a.createdAt ?? '') - const bTime = Date.parse(b.updatedAt ?? b.createdAt ?? '') - return (Number.isNaN(bTime) ? 0 : bTime) - (Number.isNaN(aTime) ? 0 : aTime) - }) + .sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id)) : [], [servers, workspaceId] ) @@ -164,6 +202,11 @@ export function EditorMcpWidgetBody({ ? (workspaceServers.find((server) => server.id === selectedServerId) ?? null) : null const selectedServerTools = selectedServerId ? getToolsByServer(selectedServerId) : [] + const serverSession = useSavedEntityYjsSession('mcp_server', selectedServerId, workspaceId) + const [formDataState, setFormDataState] = useMcpServerYjsFormData( + serverSession.doc, + defaultFormData + ) useEffect(() => { if (!workspaceId) return @@ -174,27 +217,23 @@ export function EditorMcpWidgetBody({ }, [fetchServers, workspaceId]) useEffect(() => { - if (!selectedServer) { + if (!selectedServerId || !serverSession.doc) { initializedServerIdRef.current = null - const emptyForm = createDefaultMcpServerFormData() - initialFormDataRef.current = emptyForm - setFormDataState(emptyForm) + initialFormDataRef.current = defaultFormData clearTestResult() setSaveError(null) return } - if (initializedServerIdRef.current === selectedServer.id) { + if (initializedServerIdRef.current === selectedServerId) { return } - const nextForm = createFormDataFromServer(selectedServer) - initializedServerIdRef.current = selectedServer.id - initialFormDataRef.current = nextForm - setFormDataState(nextForm) + initializedServerIdRef.current = selectedServerId + initialFormDataRef.current = formDataState clearTestResult() setSaveError(null) - }, [clearTestResult, selectedServer]) + }, [clearTestResult, defaultFormData, formDataState, selectedServerId, serverSession.doc]) useMcpSelectionPersistence({ onWidgetParamsChange, @@ -222,28 +261,39 @@ export function EditorMcpWidgetBody({ setFormDataState(initialFormDataRef.current) clearTestResult() setSaveError(null) - }, [clearTestResult]) + }, [clearTestResult, setFormDataState]) const handleTestConnection = useCallback(async () => { if (!workspaceId || !selectedServerId || !formDataState.url?.trim()) return await testConnection({ - name: formDataState.name.trim() || getServerName(selectedServer) || copy.unnamedServer, + name: formDataState.name.trim() || copy.unnamedServer, transport: formDataState.transport, url: formDataState.url, - headers: createMcpSavePayload(formDataState).headers, + headers: sanitizeRecord(formDataState.headers), timeout: formDataState.timeout, workspaceId, }) - }, [formDataState, selectedServer, selectedServerId, testConnection, workspaceId]) + }, [copy.unnamedServer, formDataState, selectedServerId, testConnection, workspaceId]) const handleRefreshTools = useCallback(async () => { - if (!workspaceId || !selectedServerId) return + if ( + !workspaceId || + !selectedServerId || + formDataState.enabled === false || + !formDataState.url?.trim() + ) { + return + } try { - await refreshServerApi(selectedServerId, workspaceId, copy.failedToRefreshMcpServer) - await refreshServer(workspaceId, selectedServerId) - await refreshTools(true) + const refreshResult = await refreshServerApi( + selectedServerId, + workspaceId, + copy.failedToRefreshMcpServer + ) + await refreshServer(workspaceId, selectedServerId, refreshResult?.data) + await refreshTools() await fetchServers(workspaceId) } catch (refreshError) { console.error('Failed to refresh MCP server tools', refreshError) @@ -252,6 +302,8 @@ export function EditorMcpWidgetBody({ }, [ copy.failedToRefreshMcpServer, fetchServers, + formDataState.enabled, + formDataState.url, refreshServer, refreshTools, selectedServerId, @@ -259,10 +311,9 @@ export function EditorMcpWidgetBody({ ]) const handleSave = useCallback(async () => { - if (!workspaceId || !selectedServerId) return + if (!workspaceId || !selectedServerId || !serverSession.doc) return - const payload = createMcpSavePayload(formDataState) - if (!payload.name) { + if (!formDataState.name.trim()) { setSaveError(copy.serverNameRequired) return } @@ -270,9 +321,14 @@ export function EditorMcpWidgetBody({ setSaveError(null) try { - await updateServer(workspaceId, selectedServerId, payload) + await serverSession.save() initialFormDataRef.current = formDataState - await fetchServers(workspaceId) + if (formDataState.enabled === false || !formDataState.url?.trim()) { + await fetchServers(workspaceId) + await refreshTools() + } else { + await handleRefreshTools() + } } catch (error) { console.error('Failed to save MCP server', error) setSaveError(copy.failedToSaveMcpServer) @@ -282,8 +338,11 @@ export function EditorMcpWidgetBody({ copy.serverNameRequired, fetchServers, formDataState, + handleRefreshTools, + refreshTools, + serverSession.doc, + serverSession.save, selectedServerId, - updateServer, workspaceId, ]) @@ -325,6 +384,18 @@ export function EditorMcpWidgetBody({ return } + if (serverSession.error) { + return + } + + if (serverSession.isLoading) { + return ( +
+ +
+ ) + } + const displayStatus = selectedServer.connectionStatus ?? 'disconnected' return ( @@ -333,7 +404,7 @@ export function EditorMcpWidgetBody({

- {getServerName(selectedServer) || copy.unnamedServer} + {formDataState.name.trim() || copy.unnamedServer}

- {selectedServer.updatedAt ? ( - - {formatTemplate(copy.updated, { - time: formatRelativeTime(selectedServer.updatedAt, copy.relativeTime) ?? '', - })} - - ) : null} {selectedServer.lastToolsRefresh ? ( {formatTemplate(copy.toolsRefreshed, { diff --git a/apps/tradinggoose/widgets/widgets/editor_skill/components/skill-editor-header.tsx b/apps/tradinggoose/widgets/widgets/editor_skill/components/skill-editor-header.tsx index c295efd2e..286b7c310 100644 --- a/apps/tradinggoose/widgets/widgets/editor_skill/components/skill-editor-header.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_skill/components/skill-editor-header.tsx @@ -1,16 +1,11 @@ 'use client' -import { useCallback, useEffect, useMemo, useState } from 'react' import { Download, Save } from 'lucide-react' import { useLocale } from 'next-intl' -import { Button } from '@/components/ui/button' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useMessages } from 'next-intl' -import { exportSkillsAsJson } from '@/lib/skills/import-export' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' -import { useSkillsStore } from '@/stores/skills/store' import type { PairColor } from '@/widgets/pair-colors' -import { emitSkillEditorAction, useSkillEditorState } from '@/widgets/utils/skill-editor-actions' +import { emitSkillEditorAction } from '@/widgets/utils/skill-editor-actions' import { emitSkillSelectionChange } from '@/widgets/utils/skill-selection' import { readEntitySelectionState, @@ -79,25 +74,6 @@ interface SkillEditorActionButtonProps { params?: Record | null } -const sanitizeFileNameSegment = (value: string) => - value - .trim() - .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-') - .replace(/\s+/g, '-') - -const downloadJsonFile = (fileName: string, content: string) => { - const blob = new Blob([content], { type: 'application/json;charset=utf-8' }) - const blobUrl = URL.createObjectURL(blob) - const link = document.createElement('a') - - link.href = blobUrl - link.download = fileName - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - URL.revokeObjectURL(blobUrl) -} - export function SkillEditorExportButton({ workspaceId, skillId, @@ -110,68 +86,18 @@ export function SkillEditorExportButton({ const resolvedPairColor = (pairColor ?? 'gray') as PairColor const isLinkedToColorPair = resolvedPairColor !== 'gray' const pairContext = usePairColorContext(resolvedPairColor) - const [isDirty, setIsDirty] = useState(true) const resolvedSkillId = isLinkedToColorPair ? (pairContext?.skillId ?? null) : (skillId ?? null) - const skill = useSkillsStore((state) => - workspaceId && resolvedSkillId ? state.readSkill(resolvedSkillId, workspaceId) : undefined - ) - - useSkillEditorState({ - panelId, - widget: widgetKey ? ({ key: widgetKey } as { key: string }) : null, - onStateChange: (detail) => { - setIsDirty(detail.isDirty) - }, - }) - - useEffect(() => { - setIsDirty(true) - }, [resolvedSkillId, workspaceId]) - - const fileName = useMemo(() => { - if (!skill?.name) { - return 'skill.json' - } - - const normalized = sanitizeFileNameSegment(skill.name) - return normalized.length > 0 ? `${normalized}.json` : 'skill.json' - }, [skill?.name]) - - const exportDisabled = !workspaceId || !resolvedSkillId || !skill || isDirty - const tooltipText = - exportDisabled && skill && isDirty ? copy.saveBeforeExporting : copy.exportSkill - - const handleExport = useCallback(() => { - if (!skill) return - - const json = exportSkillsAsJson({ - exportedFrom: 'skillEditor', - skills: [skill], - }) - - downloadJsonFile(fileName, json) - }, [fileName, skill]) + const exportDisabled = !workspaceId || !resolvedSkillId return ( - - - - - - - {tooltipText} - + emitSkillEditorAction({ action: 'export', panelId, widgetKey })} + /> ) } diff --git a/apps/tradinggoose/widgets/widgets/editor_skill/editor-skill-body.tsx b/apps/tradinggoose/widgets/widgets/editor_skill/editor-skill-body.tsx index 10457489b..32c65d558 100644 --- a/apps/tradinggoose/widgets/widgets/editor_skill/editor-skill-body.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_skill/editor-skill-body.tsx @@ -1,14 +1,14 @@ 'use client' -import { useEffect, useRef, useState } from 'react' -import { useLocale } from 'next-intl' -import { LoadingAgent } from '@/components/ui/loading-agent' +import { useEffect, useRef } from 'react' import { useMessages } from 'next-intl' +import { LoadingAgent } from '@/components/ui/loading-agent' +import { useSavedEntityYjsSession } from '@/lib/yjs/use-entity-fields' import { useSkills } from '@/hooks/queries/skills' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' import type { PairColor } from '@/widgets/pair-colors' import type { WidgetComponentProps } from '@/widgets/types' -import { emitSkillEditorState, useSkillEditorActions } from '@/widgets/utils/skill-editor-actions' +import { useSkillEditorActions } from '@/widgets/utils/skill-editor-actions' import { useSkillSelectionPersistence } from '@/widgets/utils/skill-selection' import { getSkillIdFromParams } from '@/widgets/widgets/_shared/skill/utils' import { WidgetStateMessage } from '@/widgets/widgets/editor_indicator/components/widget-state-message' @@ -24,7 +24,6 @@ export function EditorSkillWidgetBody({ widget, onWidgetParamsChange, }: EditorSkillWidgetBodyProps) { - const locale = useLocale() const copy = useMessages().workspace.widgets.skillEditor.body const workspaceId = context?.workspaceId ?? null const { data: skills = [], isLoading, error } = useSkills(workspaceId ?? '') @@ -32,8 +31,8 @@ export function EditorSkillWidgetBody({ const isLinkedToColorPair = resolvedPairColor !== 'gray' const pairContext = usePairColorContext(resolvedPairColor) const setPairContext = useSetPairColorContext() + const exportRef = useRef<() => void>(() => {}) const saveRef = useRef<() => void>(() => {}) - const [isDirty, setIsDirty] = useState(false) const paramsSkillId = getSkillIdFromParams(params) const requestedSkillId = isLinkedToColorPair ? (pairContext?.skillId ?? null) : paramsSkillId @@ -43,8 +42,11 @@ export function EditorSkillWidgetBody({ skills.some((skill) => skill.id === normalizedRequestedSkillId) const skillId = hasRequestedSkill ? normalizedRequestedSkillId - : (isLinkedToColorPair ? null : (skills[0]?.id ?? null)) + : isLinkedToColorPair + ? null + : (skills[0]?.id ?? null) const skill = skillId ? (skills.find((candidate) => candidate.id === skillId) ?? null) : null + const skillSession = useSavedEntityYjsSession('skill', skillId, workspaceId) useEffect(() => { if (!skillId) { @@ -94,31 +96,10 @@ export function EditorSkillWidgetBody({ useSkillEditorActions({ panelId, widget, + onExport: () => exportRef.current(), onSave: () => saveRef.current(), }) - useEffect(() => { - emitSkillEditorState({ - isDirty, - panelId, - widgetKey: widget?.key, - }) - - return () => { - emitSkillEditorState({ - isDirty: false, - panelId, - widgetKey: widget?.key, - }) - } - }, [isDirty, panelId, widget?.key]) - - useEffect(() => { - if (!skillId || !skill) { - setIsDirty(false) - } - }, [skill, skillId]) - if (!workspaceId) { return } @@ -157,18 +138,26 @@ export function EditorSkillWidgetBody({ return } + if (skillSession.error) { + return + } + + if (skillSession.isLoading) { + return ( +
+ +
+ ) + } + return (
) diff --git a/apps/tradinggoose/widgets/widgets/editor_skill/index.test.tsx b/apps/tradinggoose/widgets/widgets/editor_skill/index.test.tsx index 7ff6460d8..969a1279d 100644 --- a/apps/tradinggoose/widgets/widgets/editor_skill/index.test.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_skill/index.test.tsx @@ -6,8 +6,7 @@ import type { ReactNode } from 'react' import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useSkillsStore } from '@/stores/skills/store' -import { emitSkillEditorState } from '@/widgets/utils/skill-editor-actions' +import { SKILL_EDITOR_ACTION_EVENT, type SkillEditorActionEventDetail } from '@/widgets/events' import { editorSkillWidget } from '@/widgets/widgets/editor_skill' vi.mock('@/components/ui/tooltip', () => ({ @@ -24,20 +23,9 @@ const reactActEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } -const readBlobText = async (blob: Blob) => - await new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onload = () => resolve(String(reader.result ?? '')) - reader.onerror = () => reject(reader.error) - reader.readAsText(blob) - }) - describe('Skill Editor header controls', () => { let container: HTMLDivElement let root: Root - let createObjectUrlSpy: ReturnType - let revokeObjectUrlSpy: ReturnType - let clickSpy: ReturnType beforeEach(() => { vi.clearAllMocks() @@ -45,37 +33,6 @@ describe('Skill Editor header controls', () => { container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) - - useSkillsStore.getState().resetAll() - useSkillsStore.getState().setSkills('workspace-1', [ - { - id: 'skill-1', - workspaceId: 'workspace-1', - userId: 'user-1', - name: 'Market Research', - description: 'Investigate the market.', - content: 'Use multiple trusted sources.', - createdAt: '2026-04-06T12:00:00.000Z', - updatedAt: '2026-04-06T12:00:00.000Z', - }, - ]) - - createObjectUrlSpy = vi.fn(() => 'blob:skill-export') - revokeObjectUrlSpy = vi.fn() - clickSpy = vi.fn() - - Object.defineProperty(globalThis.URL, 'createObjectURL', { - configurable: true, - value: createObjectUrlSpy, - }) - Object.defineProperty(globalThis.URL, 'revokeObjectURL', { - configurable: true, - value: revokeObjectUrlSpy, - }) - Object.defineProperty(HTMLAnchorElement.prototype, 'click', { - configurable: true, - value: clickSpy, - }) }) afterEach(() => { @@ -83,7 +40,6 @@ describe('Skill Editor header controls', () => { root.unmount() }) container.remove() - useSkillsStore.getState().resetAll() }) it('renders Export skill immediately left of Save skill', async () => { @@ -125,7 +81,12 @@ describe('Skill Editor header controls', () => { expect(buttons[0]?.hasAttribute('disabled')).toBe(true) }) - it('disables export while the editor is dirty and re-enables it when the editor becomes clean', async () => { + it('emits export for the selected skill', async () => { + const actionSpy = vi.fn() + const handler = (event: Event) => { + actionSpy((event as CustomEvent).detail) + } + window.addEventListener(SKILL_EDITOR_ACTION_EVENT, handler) const header = editorSkillWidget.renderHeader?.({ context: { workspaceId: 'workspace-1' } as any, panelId: 'panel-1', @@ -143,40 +104,24 @@ describe('Skill Editor header controls', () => { const buttons = Array.from(container.querySelectorAll('button')) const exportButton = buttons[0] - expect(exportButton?.hasAttribute('disabled')).toBe(true) - - await act(async () => { - emitSkillEditorState({ - isDirty: false, - panelId: 'panel-1', - widgetKey: 'editor_skill', - }) - }) - - expect(exportButton?.hasAttribute('disabled')).toBe(false) - await act(async () => { - emitSkillEditorState({ - isDirty: true, - panelId: 'panel-1', - widgetKey: 'editor_skill', - }) + exportButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) - expect(exportButton?.hasAttribute('disabled')).toBe(true) - - await act(async () => { - emitSkillEditorState({ - isDirty: false, - panelId: 'panel-1', - widgetKey: 'editor_skill', - }) + expect(actionSpy).toHaveBeenCalledWith({ + action: 'export', + panelId: 'panel-1', + widgetKey: 'editor_skill', }) - - expect(exportButton?.hasAttribute('disabled')).toBe(false) + window.removeEventListener(SKILL_EDITOR_ACTION_EVENT, handler) }) - it('downloads the unified export envelope for the selected skill', async () => { + it('emits save for the selected skill', async () => { + const actionSpy = vi.fn() + const handler = (event: Event) => { + actionSpy((event as CustomEvent).detail) + } + window.addEventListener(SKILL_EDITOR_ACTION_EVENT, handler) const header = editorSkillWidget.renderHeader?.({ context: { workspaceId: 'workspace-1' } as any, panelId: 'panel-1', @@ -192,43 +137,17 @@ describe('Skill Editor header controls', () => { }) const buttons = Array.from(container.querySelectorAll('button')) - const exportButton = buttons[0] + const saveButton = buttons[1] await act(async () => { - emitSkillEditorState({ - isDirty: false, - panelId: 'panel-1', - widgetKey: 'editor_skill', - }) + saveButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) - await act(async () => { - exportButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - expect(createObjectUrlSpy).toHaveBeenCalledTimes(1) - expect(clickSpy).toHaveBeenCalledTimes(1) - expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:skill-export') - - const blob = createObjectUrlSpy.mock.calls[0]?.[0] as Blob - const payload = JSON.parse(await readBlobText(blob)) - - expect(payload).toMatchObject({ - version: '1', - fileType: 'tradingGooseExport', - exportedFrom: 'skillEditor', - resourceTypes: ['skills'], - skills: [ - { - name: 'Market Research', - description: 'Investigate the market.', - content: 'Use multiple trusted sources.', - }, - ], - workflows: [], - customTools: [], - watchlists: [], - indicators: [], + expect(actionSpy).toHaveBeenCalledWith({ + action: 'save', + panelId: 'panel-1', + widgetKey: 'editor_skill', }) + window.removeEventListener(SKILL_EDITOR_ACTION_EVENT, handler) }) }) diff --git a/apps/tradinggoose/widgets/widgets/editor_skill/skill-editor.test.tsx b/apps/tradinggoose/widgets/widgets/editor_skill/skill-editor.test.tsx index 77f6c527d..854fc2303 100644 --- a/apps/tradinggoose/widgets/widgets/editor_skill/skill-editor.test.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_skill/skill-editor.test.tsx @@ -6,23 +6,15 @@ import type { MutableRefObject } from 'react' import { act, createRef } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as Y from 'yjs' +import { seedEntitySession } from '@/lib/yjs/entity-session' import { SkillEditor } from '@/widgets/widgets/editor_skill/skill-editor' -const mockUseUpdateSkill = vi.fn() - -vi.mock('@/hooks/queries/skills', async () => { - const actual = await vi.importActual('@/hooks/queries/skills') - return { - ...actual, - useUpdateSkill: () => mockUseUpdateSkill(), - } -}) - const reactActEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } -describe('SkillEditor dirty state', () => { +describe('SkillEditor save', () => { let container: HTMLDivElement let root: Root @@ -41,29 +33,28 @@ describe('SkillEditor dirty state', () => { container.remove() }) - it('returns to a clean state after a successful save', async () => { - const mutateAsync = vi.fn().mockResolvedValue({}) - const onDirtyChange = vi.fn() + it('saves the current Yjs fields', async () => { + const save = vi.fn().mockResolvedValue(undefined) + const exportRef = createRef<() => void>() const saveRef = createRef<() => void>() + const doc = new Y.Doc() + const initialValues = { + id: 'skill-1', + name: 'Market Research', + description: 'Investigate the market.', + content: 'Use multiple trusted sources.', + } saveRef.current = () => {} - - mockUseUpdateSkill.mockReturnValue({ - isPending: false, - mutateAsync, - }) + seedEntitySession(doc, { entityKind: 'skill', payload: initialValues }) await act(async () => { root.render( void>} saveRef={saveRef as MutableRefObject<() => void>} - onDirtyChange={onDirtyChange} - initialValues={{ - id: 'skill-1', - name: 'Market Research', - description: 'Investigate the market.', - content: 'Use multiple trusted sources.', - }} + skillId='skill-1' + doc={doc} + save={save} /> ) }) @@ -78,22 +69,12 @@ describe('SkillEditor dirty state', () => { nameInput!.dispatchEvent(new Event('change', { bubbles: true })) }) - expect(onDirtyChange).toHaveBeenLastCalledWith(true) - await act(async () => { saveRef.current?.() await Promise.resolve() }) - expect(mutateAsync).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - skillId: 'skill-1', - updates: { - name: 'Market Research Updated', - description: 'Investigate the market.', - content: 'Use multiple trusted sources.', - }, - }) - expect(onDirtyChange).toHaveBeenLastCalledWith(false) + expect(save).toHaveBeenCalledTimes(1) + doc.destroy() }) }) diff --git a/apps/tradinggoose/widgets/widgets/editor_skill/skill-editor.tsx b/apps/tradinggoose/widgets/widgets/editor_skill/skill-editor.tsx index 93f39d603..6135406c1 100644 --- a/apps/tradinggoose/widgets/widgets/editor_skill/skill-editor.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_skill/skill-editor.tsx @@ -1,82 +1,41 @@ import { type MutableRefObject, useCallback, useEffect, useState } from 'react' import { AlertTriangle } from 'lucide-react' +import type * as Y from 'yjs' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' +import { createLogger } from '@/lib/logs/console/logger' +import { exportSkillsAsJson, SKILL_NAME_MAX_LENGTH } from '@/lib/skills/import-export' +import { useYjsStringField } from '@/lib/yjs/use-entity-fields' +import { isValidSkillName } from '@/hooks/queries/skills' import { formatTemplate } from '@/i18n/utils' import { useWorkspaceWidgetsMessages } from '@/i18n/workspace-widget-hooks' -import { createLogger } from '@/lib/logs/console/logger' -import { SKILL_NAME_MAX_LENGTH } from '@/lib/skills/import-export' -import { isValidSkillName, useUpdateSkill } from '@/hooks/queries/skills' -import { useSkillsStore } from '@/stores/skills/store' const logger = createLogger('SkillEditor') -interface SkillInitialValues { - id: string - name: string - description: string - content: string -} - interface SkillEditorProps { - workspaceId: string - initialValues: SkillInitialValues + skillId: string + doc: Y.Doc | null + save: () => Promise + exportRef: MutableRefObject<() => void> saveRef: MutableRefObject<() => void> - onDirtyChange?: (isDirty: boolean) => void } -export function SkillEditor({ - workspaceId, - initialValues, - saveRef, - onDirtyChange, -}: SkillEditorProps) { +export function SkillEditor({ skillId, doc, save, exportRef, saveRef }: SkillEditorProps) { const copy = useWorkspaceWidgetsMessages().skillEditor - const [name, setName] = useState('') - const [description, setDescription] = useState('') - const [content, setContent] = useState('') + const [name, setName] = useYjsStringField(doc, 'name') + const [description, setDescription] = useYjsStringField(doc, 'description') + const [content, setContent] = useYjsStringField(doc, 'content') const [error, setError] = useState(null) const [isSaving, setIsSaving] = useState(false) - const [savedValues, setSavedValues] = useState({ - name: '', - description: '', - content: '', - }) - - const updateSkillMutation = useUpdateSkill() useEffect(() => { - const nextSavedValues = { - name: initialValues.name, - description: initialValues.description, - content: initialValues.content, - } - - setName(nextSavedValues.name) - setDescription(nextSavedValues.description) - setContent(nextSavedValues.content) - setSavedValues(nextSavedValues) setError(null) - }, [initialValues.content, initialValues.description, initialValues.id, initialValues.name]) - - useEffect(() => { - onDirtyChange?.( - name !== savedValues.name || - description !== savedValues.description || - content !== savedValues.content - ) - }, [ - content, - description, - name, - onDirtyChange, - savedValues.content, - savedValues.description, - savedValues.name, - ]) + }, [doc, skillId]) const handleSave = useCallback(async () => { + if (!doc) return + const trimmedName = name.trim() const trimmedDescription = description.trim() const trimmedContent = content.trim() @@ -101,50 +60,46 @@ export function SkillEditor({ return } - const existingSkills = useSkillsStore.getState().getAllSkills(workspaceId) - const isDuplicate = existingSkills.some((skill) => { - if (skill.id === initialValues.id) { - return false - } - - return skill.name === trimmedName - }) - - if (isDuplicate) { - setError(formatTemplate(copy.validation.duplicateName, { name: trimmedName })) - return - } - setIsSaving(true) setError(null) try { - await updateSkillMutation.mutateAsync({ - workspaceId, - skillId: initialValues.id, - updates: { - name: trimmedName, - description: trimmedDescription, - content: trimmedContent, - }, - }) - - setName(trimmedName) - setDescription(trimmedDescription) - setContent(trimmedContent) - setSavedValues({ - name: trimmedName, - description: trimmedDescription, - content: trimmedContent, - }) + await save() } catch (saveError) { const message = saveError instanceof Error ? saveError.message : copy.validation.saveFailed - logger.error('Failed to save skill', { error: saveError, skillId: initialValues.id }) + logger.error('Failed to save skill', { error: saveError, skillId }) setError(message) } finally { setIsSaving(false) } - }, [content, description, initialValues.id, name, updateSkillMutation, workspaceId]) + }, [content, copy.validation, description, doc, name, save, skillId]) + + const handleExport = useCallback(() => { + if (!doc) return + const json = exportSkillsAsJson({ + exportedFrom: 'skillEditor', + skills: [{ name, description, content }], + }) + const fileNameBase = + name + .trim() + .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-') + .replace(/\s+/g, '-') || 'skill' + const blobUrl = URL.createObjectURL( + new Blob([json], { type: 'application/json;charset=utf-8' }) + ) + const link = document.createElement('a') + link.href = blobUrl + link.download = `${fileNameBase}.json` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(blobUrl) + }, [content, description, doc, name]) + + useEffect(() => { + exportRef.current = handleExport + }, [exportRef, handleExport]) useEffect(() => { saveRef.current = () => { @@ -162,12 +117,10 @@ export function SkillEditor({ value={name} onChange={(event) => setName(event.target.value)} placeholder={copy.form.namePlaceholder} - disabled={isSaving} + disabled={!doc || isSaving} maxLength={SKILL_NAME_MAX_LENGTH} /> -

- {copy.form.helperText} -

+

{copy.form.helperText}

@@ -177,7 +130,7 @@ export function SkillEditor({ value={description} onChange={(event) => setDescription(event.target.value)} placeholder={copy.form.descriptionPlaceholder} - disabled={isSaving} + disabled={!doc || isSaving} maxLength={1024} />
@@ -189,7 +142,7 @@ export function SkillEditor({ value={content} onChange={(event) => setContent(event.target.value)} placeholder={copy.form.instructionsPlaceholder} - disabled={isSaving} + disabled={!doc || isSaving} className='min-h-[320px] resize-y font-mono text-sm' maxLength={50000} /> diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts index 0518cfa8d..d960a0107 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/auto-layout.ts @@ -14,40 +14,6 @@ interface AutoLayoutOptions { } } -function sanitizeEdgesForStateSave(edges: any[]): any[] { - return edges.flatMap((edge: any, index: number) => { - const source = typeof edge?.source === 'string' ? edge.source.trim() : '' - const target = typeof edge?.target === 'string' ? edge.target.trim() : '' - - if (!source || !target) { - return [] - } - - const sourceHandle = - typeof edge?.sourceHandle === 'string' && edge.sourceHandle.length > 0 - ? edge.sourceHandle - : undefined - const targetHandle = - typeof edge?.targetHandle === 'string' && edge.targetHandle.length > 0 - ? edge.targetHandle - : undefined - - return [ - { - ...edge, - id: - typeof edge?.id === 'string' && edge.id.length > 0 - ? edge.id - : `${source}-${sourceHandle || 'source'}-${target}-${targetHandle || 'target'}-${index}`, - source, - target, - ...(sourceHandle ? { sourceHandle } : {}), - ...(targetHandle ? { targetHandle } : {}), - }, - ] - }) -} - export async function applyAutoLayoutToWorkflow( workflowId: string, blocks: Record, @@ -55,7 +21,6 @@ export async function applyAutoLayoutToWorkflow( options: AutoLayoutOptions = {} ): Promise<{ success: boolean - layoutedBlocks?: Record error?: string }> { try { @@ -118,14 +83,11 @@ export async function applyAutoLayoutToWorkflow( logger.info('Successfully applied auto layout', { workflowId, originalBlockCount: Object.keys(blocks).length, - layoutedBlockCount: result.data?.layoutedBlocks - ? Object.keys(result.data.layoutedBlocks).length - : 0, + layoutedBlockCount: result.data?.blockCount ?? 0, }) return { success: true, - layoutedBlocks: result.data?.layoutedBlocks || blocks, } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown auto layout error' @@ -138,17 +100,17 @@ export async function applyAutoLayoutToWorkflow( } } -interface ApplyAutoLayoutAndUpdateStoreParams { +interface ApplyAutoLayoutParams { workflowId: string channelId?: string options?: AutoLayoutOptions } -export async function applyAutoLayoutAndUpdateStore({ +export async function applyAutoLayoutToActiveWorkflow({ workflowId, channelId, options = {}, -}: ApplyAutoLayoutAndUpdateStoreParams): Promise<{ +}: ApplyAutoLayoutParams): Promise<{ success: boolean error?: string }> { @@ -156,8 +118,7 @@ export async function applyAutoLayoutAndUpdateStore({ try { const { getRegisteredWorkflowSession } = await import('@/lib/yjs/workflow-session-registry') - const { readWorkflowSnapshot, readWorkflowMap } = await import('@/lib/yjs/workflow-session') - const { YJS_ORIGINS } = await import('@/lib/yjs/transaction-origins') + const { readWorkflowSnapshot } = await import('@/lib/yjs/workflow-session') const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') const registryState = useWorkflowRegistry.getState() @@ -212,95 +173,15 @@ export async function applyAutoLayoutAndUpdateStore({ const result = await applyAutoLayoutToWorkflow(resolvedWorkflowId, blocks, edges, options) - if (!result.success || !result.layoutedBlocks) { + if (!result.success) { return { success: false, error: result.error } } - const doc = session.doc - doc.transact(() => { - const wMap = readWorkflowMap(doc) - wMap.set('blocks', result.layoutedBlocks!) - wMap.set('lastSaved', Date.now()) - }, YJS_ORIGINS.USER) - - logger.info('Successfully updated Yjs doc with auto layout', { + logger.info('Successfully applied durable auto layout', { workflowId: resolvedWorkflowId, channelId, }) - - try { - const updatedSnapshot = readWorkflowSnapshot(doc) - - const stateToSave = { - ...updatedSnapshot, - deploymentStatuses: undefined, - needsRedeployment: undefined, - dragStartPosition: undefined, - } - - const cleanedWorkflowState = { - ...stateToSave, - deployedAt: (stateToSave as any).deployedAt - ? new Date((stateToSave as any).deployedAt) - : undefined, - loops: stateToSave.loops || {}, - parallels: stateToSave.parallels || {}, - edges: sanitizeEdgesForStateSave(stateToSave.edges || []), - } - - const response = await fetch(`/api/workflows/${resolvedWorkflowId}/state`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(cleanedWorkflowState), - }) - - if (!response.ok) { - let errorMessage = `HTTP ${response.status}: ${response.statusText}` - try { - const errorData = await response.json() - const details = - typeof errorData?.details === 'string' - ? errorData.details - : JSON.stringify(errorData?.details || errorData) - errorMessage = errorData?.error - ? `${errorData.error}${details ? ` - ${details}` : ''}` - : errorMessage - } catch { - // Ignore JSON parse errors and fall back to generic message - } - - throw new Error(errorMessage) - } - - logger.info('Auto layout successfully persisted to database', { - workflowId: resolvedWorkflowId, - channelId, - }) - return { success: true } - } catch (saveError) { - const message = - saveError instanceof Error && saveError.message - ? saveError.message - : JSON.stringify(saveError) - logger.error('Failed to save auto layout to database, reverting Yjs doc:', { - workflowId: resolvedWorkflowId, - error: message, - }) - - doc.transact(() => { - const wMap = readWorkflowMap(doc) - wMap.set('blocks', blocks) - }, YJS_ORIGINS.SYSTEM) - - return { - success: false, - error: `Failed to save positions to database: ${ - saveError instanceof Error ? saveError.message : 'Unknown error' - }`, - } - } + return { success: true } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown store update error' logger.error('Failed to update store with auto layout:', { diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/export-controls/export-controls.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/export-controls/export-controls.tsx index ddef3c815..5e16b0a8b 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/export-controls/export-controls.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/components/export-controls/export-controls.tsx @@ -6,7 +6,6 @@ import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { widgetHeaderIconButtonClassName } from '@/components/widget-header-control' import { createLogger } from '@/lib/logs/console/logger' -import { useSkills } from '@/hooks/queries/skills' import { useWorkflowJsonStore } from '@/stores/workflows/json/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import { useWorkflowEditorCopy } from '@/widgets/widgets/editor_workflow/copy' @@ -29,10 +28,6 @@ export function ExportControls({ disabled = false, variant = 'workspace' }: Expo const { getJson: readWorkflowExportJson } = useWorkflowJsonStore() const currentWorkflow = workflowId ? workflows[workflowId] : null - const workflowWorkspaceId = currentWorkflow?.workspaceId ?? null - const { data: workspaceSkills = [], refetch: refetchWorkspaceSkills } = useSkills( - workflowWorkspaceId ?? '' - ) const downloadFile = (content: string, filename: string, mimeType: string) => { try { @@ -58,13 +53,9 @@ export function ExportControls({ disabled = false, variant = 'workspace' }: Expo setIsExporting(true) try { - const refreshedSkills = workflowWorkspaceId ? await refetchWorkspaceSkills() : null - const exportWorkspaceSkills = refreshedSkills?.data ?? workspaceSkills - const jsonContent = await readWorkflowExportJson({ workflowId, channelId, - workspaceSkills: exportWorkspaceSkills, }) if (!jsonContent) { diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/control-bar.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/control-bar.tsx index 1e7837bf4..bcd8dd20b 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/control-bar.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/control-bar/control-bar.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useMemo, useState } from 'react' -import { ChevronDown, LayoutDashboard, Play, RefreshCw, X } from 'lucide-react' +import { AlertTriangle, ChevronDown, LayoutDashboard, Play, RefreshCw, X } from 'lucide-react' import { Button, DropdownMenu, @@ -110,6 +110,7 @@ export function ControlBar({ // Local state const [, forceUpdate] = useState({}) const [isAutoLayouting, setIsAutoLayouting] = useState(false) + const [autoLayoutError, setAutoLayoutError] = useState(null) // Deployed state management const [deployedState, setDeployedState] = useState(null) @@ -349,13 +350,13 @@ export function ControlBar({ } setIsAutoLayouting(true) + setAutoLayoutError(null) try { - // Use the shared auto layout utility for immediate frontend updates - const { applyAutoLayoutAndUpdateStore } = await import( + const { applyAutoLayoutToActiveWorkflow } = await import( '@/widgets/widgets/editor_workflow/components/control-bar/auto-layout' ) - const result = await applyAutoLayoutAndUpdateStore({ + const result = await applyAutoLayoutToActiveWorkflow({ workflowId: activeWorkflowId!, channelId, }) @@ -364,11 +365,11 @@ export function ControlBar({ logger.info('Auto layout completed successfully') } else { logger.error('Auto layout failed:', result.error) - // You could add a toast notification here if available + setAutoLayoutError(result.error ?? 'Auto layout failed') } } catch (error) { logger.error('Auto layout error:', error) - // You could add a toast notification here if available + setAutoLayoutError(error instanceof Error ? error.message : 'Auto layout failed') } finally { setIsAutoLayouting(false) } @@ -562,6 +563,19 @@ export function ControlBar({ return (
+ {autoLayoutError ? ( + + +
+ + Auto layout failed +
+
+ + {autoLayoutError} + +
+ ) : null} {showOptionalControls && } {showOptionalControls && renderAutoLayoutButton()} {renderDeployButton()} diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx index 0b0ef63a7..0909ebfd5 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/knowledge-base-selector/knowledge-base-selector.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Check, ChevronDown, RefreshCw, X } from 'lucide-react' -import { useLocale } from 'next-intl' +import { useLocale, useMessages } from 'next-intl' import { PackageSearchIcon } from '@/components/icons/icons' import { Button } from '@/components/ui/button' import { @@ -17,9 +17,8 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover import type { SubBlockConfig } from '@/blocks/types' import { fetchKnowledgeBases as fetchWorkspaceKnowledgeBases } from '@/hooks/queries/knowledge' import { translateWorkflowLabel } from '@/i18n/block-editor' -import { useMessages } from 'next-intl' -import { formatTemplate } from '@/i18n/utils' import type { LocaleCode } from '@/i18n/utils' +import { formatTemplate } from '@/i18n/utils' import type { KnowledgeBaseData } from '@/stores/knowledge/store' import { useSubBlockValue } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-sub-block-value' import { useWorkspaceId } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' @@ -171,14 +170,6 @@ export function KnowledgeBaseSelector({ } const getKnowledgeBaseDescription = (knowledgeBase: KnowledgeBaseData) => { - const docCount = (knowledgeBase as any).docCount - if (docCount !== undefined) { - const documentLabel = - docCount === 1 - ? translateWorkflowLabel(locale, 'document') - : translateWorkflowLabel(locale, 'documents') - return `${docCount} ${documentLabel}` - } return knowledgeBase.description || translateWorkflowLabel(locale, 'noDescription') } diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx index 9355c71db..69e4d62c0 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/components/mcp-server-modal/mcp-server-selector.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { Check, ChevronDown, RefreshCw } from 'lucide-react' +import { useMessages } from 'next-intl' import { Button } from '@/components/ui/button' import { Command, @@ -13,7 +14,6 @@ import { } from '@/components/ui/command' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import type { SubBlockConfig } from '@/blocks/types' -import { useMessages } from 'next-intl' import { useEnabledServers, useMcpServersStore } from '@/stores/mcp-servers/store' import { useSubBlockValue } from '@/widgets/widgets/editor_workflow/components/workflow-block/components/sub-block/hooks/use-sub-block-value' import { useWorkspaceId } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' diff --git a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/workflow-canvas.tsx b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/workflow-canvas.tsx index ab3c9a7ad..0443b8d88 100644 --- a/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/workflow-canvas.tsx +++ b/apps/tradinggoose/widgets/widgets/editor_workflow/components/workflow-editor/workflow-canvas.tsx @@ -448,17 +448,16 @@ const WorkflowCanvas = React.memo( [getNodes, blocks] ) - // Auto-layout handler - now uses frontend auto layout for immediate updates + // Auto-layout handler const handleAutoLayout = useCallback(async () => { if (Object.keys(blocks).length === 0) return try { - // Use the shared auto layout utility for immediate frontend updates - const { applyAutoLayoutAndUpdateStore } = await import( + const { applyAutoLayoutToActiveWorkflow } = await import( '@/widgets/widgets/editor_workflow/components/control-bar/auto-layout' ) - const result = await applyAutoLayoutAndUpdateStore({ + const result = await applyAutoLayoutToActiveWorkflow({ workflowId: activeWorkflowId!, channelId: resolvedChannelId, }) diff --git a/apps/tradinggoose/widgets/widgets/heatmap/components/heatmap-treemap-chart.test.tsx b/apps/tradinggoose/widgets/widgets/heatmap/components/heatmap-treemap-chart.test.tsx index 60ec2f4a1..8e1889d6c 100644 --- a/apps/tradinggoose/widgets/widgets/heatmap/components/heatmap-treemap-chart.test.tsx +++ b/apps/tradinggoose/widgets/widgets/heatmap/components/heatmap-treemap-chart.test.tsx @@ -323,7 +323,7 @@ describe('HeatmapTreemapChart', () => { const button = container.querySelector('button') const icon = container.querySelector('img') - expect(button?.textContent?.trim()).toBe('') + await vi.waitFor(() => expect(button?.textContent?.trim()).toBe('')) expect(icon?.style.width).toBe('16px') expect(icon?.style.height).toBe('16px') }) diff --git a/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx b/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx index c4820d423..a3e960989 100644 --- a/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_custom_tool/index.tsx @@ -2,7 +2,7 @@ import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Plus, Upload, Wrench } from 'lucide-react' -import { useLocale } from 'next-intl' +import { useLocale, useMessages } from 'next-intl' import { DropdownMenu, DropdownMenuContent, @@ -19,8 +19,6 @@ import { widgetHeaderMenuItemClassName, widgetHeaderMenuTextClassName, } from '@/components/widget-header-control' -import { useMessages } from 'next-intl' -import type { LocaleCode } from '@/i18n/utils' import { parseImportedCustomToolsFile } from '@/lib/custom-tools/import-export' import { cn } from '@/lib/utils' import { @@ -34,6 +32,7 @@ import { useImportCustomTools, useUpdateCustomTool, } from '@/hooks/queries/custom-tools' +import type { LocaleCode } from '@/i18n/utils' import { useCustomToolsStore } from '@/stores/custom-tools/store' import type { CustomToolDefinition } from '@/stores/custom-tools/types' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' @@ -54,17 +53,11 @@ import { WidgetStateMessage } from '@/widgets/widgets/editor_indicator/component const DEFAULT_CUSTOM_TOOL_NAME = 'newCustomTool' const sortCustomTools = (tools: CustomToolDefinition[]) => - [...tools].sort((a, b) => { - const aTime = Date.parse(a.updatedAt ?? a.createdAt ?? '') - const bTime = Date.parse(b.updatedAt ?? b.createdAt ?? '') - return (Number.isNaN(bTime) ? 0 : bTime) - (Number.isNaN(aTime) ? 0 : aTime) - }) + [...tools].sort((a, b) => a.title.localeCompare(b.title)) const buildNewCustomToolDraft = (tools: CustomToolDefinition[]) => { const existingTitles = new Set( - tools - .map((tool) => tool.title.trim()) - .filter((title): title is string => Boolean(title)) + tools.map((tool) => tool.title.trim()).filter((title): title is string => Boolean(title)) ) let nextTitle = DEFAULT_CUSTOM_TOOL_NAME diff --git a/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-create-menu.tsx b/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-create-menu.tsx index 915ec00f6..6ee10f40e 100644 --- a/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-create-menu.tsx +++ b/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-create-menu.tsx @@ -2,7 +2,7 @@ import { type ChangeEvent, useCallback, useRef } from 'react' import { Plus, Upload } from 'lucide-react' -import { useLocale } from 'next-intl' +import { useLocale, useMessages } from 'next-intl' import { DropdownMenu, DropdownMenuContent, @@ -10,8 +10,6 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { useMessages } from 'next-intl' -import { cn } from '@/lib/utils' import { widgetHeaderIconButtonClassName, widgetHeaderMenuContentClassName, @@ -19,6 +17,7 @@ import { widgetHeaderMenuItemClassName, widgetHeaderMenuTextClassName, } from '@/components/widget-header-control' +import { cn } from '@/lib/utils' interface IndicatorCreateMenuProps { disabled?: boolean diff --git a/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-list/components/indicator-list-item.tsx b/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-list/components/indicator-list-item.tsx index 0fc8b85b4..ddcd905f4 100644 --- a/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-list/components/indicator-list-item.tsx +++ b/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-list/components/indicator-list-item.tsx @@ -1,8 +1,8 @@ 'use client' import { useEffect, useRef, useState } from 'react' -import { Copy, Activity, Pencil, Trash2 } from 'lucide-react' -import { useLocale } from 'next-intl' +import { Activity, Copy, Pencil, Trash2 } from 'lucide-react' +import { useLocale, useMessages } from 'next-intl' import { AlertDialog, AlertDialogCancel, @@ -14,7 +14,6 @@ import { } from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { useMessages } from 'next-intl' import { cn } from '@/lib/utils' import type { IndicatorDefinition } from '@/stores/indicators/types' diff --git a/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-list/indicator-list.tsx b/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-list/indicator-list.tsx index 3118e358e..3bcd48007 100644 --- a/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-list/indicator-list.tsx +++ b/apps/tradinggoose/widgets/widgets/list_indicator/components/indicator-list/indicator-list.tsx @@ -1,10 +1,9 @@ 'use client' import { useCallback, useMemo, useState } from 'react' -import { useLocale } from 'next-intl' +import { useLocale, useMessages } from 'next-intl' import { LoadingAgent } from '@/components/ui/loading-agent' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { useMessages } from 'next-intl' import { useCreateIndicator, useDeleteIndicator, @@ -160,10 +159,6 @@ export function IndicatorList({ indicator: { name: copiedName, pineCode: indicator.pineCode ?? '', - inputMeta: - indicator.inputMeta && typeof indicator.inputMeta === 'object' - ? indicator.inputMeta - : null, }, }) const copiedIndicatorId = @@ -184,12 +179,7 @@ export function IndicatorList({ }) } }, - [ - createMutation, - handleSelect, - permissions.canEdit, - workspaceId, - ] + [createMutation, handleSelect, permissions.canEdit, workspaceId] ) if (isLoading) { diff --git a/apps/tradinggoose/widgets/widgets/list_indicator/index.test.tsx b/apps/tradinggoose/widgets/widgets/list_indicator/index.test.tsx index 45816566f..5a4d47c60 100644 --- a/apps/tradinggoose/widgets/widgets/list_indicator/index.test.tsx +++ b/apps/tradinggoose/widgets/widgets/list_indicator/index.test.tsx @@ -166,7 +166,15 @@ describe('Indicator List header controls', () => { expect(mutateAsync).toHaveBeenCalledWith({ workspaceId: 'workspace-1', - file: filePayload, + file: { + ...filePayload, + indicators: [ + { + name: 'RSI Export Example', + pineCode: "indicator('RSI Export Example')", + }, + ], + }, }) }) diff --git a/apps/tradinggoose/widgets/widgets/list_indicator/index.tsx b/apps/tradinggoose/widgets/widgets/list_indicator/index.tsx index e14d9fcec..f4720724c 100644 --- a/apps/tradinggoose/widgets/widgets/list_indicator/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_indicator/index.tsx @@ -2,8 +2,8 @@ import { useCallback } from 'react' import { ListChecks } from 'lucide-react' +import { useLocale, useMessages } from 'next-intl' import { widgetHeaderButtonGroupClassName } from '@/components/widget-header-control' -import { useLocale } from 'next-intl' import { parseImportedIndicatorsFile } from '@/lib/indicators/import-export' import { useUserPermissionsContext, @@ -16,17 +16,13 @@ import type { IndicatorDefinition } from '@/stores/indicators/types' import type { PairColor } from '@/widgets/pair-colors' import type { DashboardWidgetDefinition, WidgetComponentProps } from '@/widgets/types' import { emitIndicatorSelectionChange } from '@/widgets/utils/indicator-selection' -import { useMessages } from 'next-intl' import { IndicatorCreateMenu } from '@/widgets/widgets/list_indicator/components/indicator-create-menu' import { IndicatorList, IndicatorListMessage, } from '@/widgets/widgets/list_indicator/components/indicator-list/indicator-list' -const buildNewIndicator = ( - indicators: IndicatorDefinition[], - defaults: { name: string } -) => { +const buildNewIndicator = (indicators: IndicatorDefinition[], defaults: { name: string }) => { const existingNames = new Set( indicators.map((indicator) => indicator.name.trim()).filter((name) => name.length > 0) ) diff --git a/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx b/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx index 24c88f50c..2cfc1d003 100644 --- a/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_mcp/index.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Pencil, Plus, Server, Trash2 } from 'lucide-react' +import { useMessages } from 'next-intl' import { shallow } from 'zustand/shallow' import { AlertDialog, @@ -35,7 +36,6 @@ import { WorkspacePermissionsProvider, } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useMcpTools } from '@/hooks/use-mcp-tools' -import { useMessages } from 'next-intl' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' import { useMcpServersStore } from '@/stores/mcp-servers/store' import type { McpServerWithStatus } from '@/stores/mcp-servers/types' @@ -47,6 +47,7 @@ import { resolveMcpServerId } from '@/widgets/widgets/_shared/mcp/utils' const buildDefaultMcpServer = (name: string) => ({ ...MCP_SERVER_DEFAULTS, + enabled: false, name, transport: 'streamable-http' as const, }) @@ -214,7 +215,7 @@ const ListMcpWidgetContent = ({ const permissions = useUserPermissionsContext() const [hasRequestedLoad, setHasRequestedLoad] = useState(false) const [deletingIds, setDeletingIds] = useState>(new Set()) - const { servers, isLoading, error, fetchServers, deleteServer, updateServer } = + const { servers, isLoading, error, fetchServers, deleteServer, renameServer } = useMcpServersStore( (state) => ({ servers: state.servers, @@ -222,7 +223,7 @@ const ListMcpWidgetContent = ({ error: state.error, fetchServers: state.fetchServers, deleteServer: state.deleteServer, - updateServer: state.updateServer, + renameServer: state.renameServer, }), shallow ) @@ -237,11 +238,7 @@ const ListMcpWidgetContent = ({ workspaceId ? servers .filter((server) => server.workspaceId === workspaceId && !server.deletedAt) - .sort((a, b) => { - const aTime = Date.parse(a.updatedAt ?? a.createdAt ?? '') - const bTime = Date.parse(b.updatedAt ?? b.createdAt ?? '') - return (Number.isNaN(bTime) ? 0 : bTime) - (Number.isNaN(aTime) ? 0 : aTime) - }) + .sort((a, b) => getServerName(a, '').localeCompare(getServerName(b, ''))) : [], [servers, workspaceId] ) @@ -354,11 +351,10 @@ const ListMcpWidgetContent = ({ async (serverId: string, name: string) => { if (!workspaceId || !permissions.canEdit) return - await updateServer(workspaceId, serverId, { - name, - }) + await renameServer(workspaceId, serverId, name) + await refreshTools() }, - [permissions.canEdit, updateServer, workspaceId] + [permissions.canEdit, refreshTools, renameServer, workspaceId] ) const handleDeleteServer = useCallback( @@ -369,7 +365,7 @@ const ListMcpWidgetContent = ({ setDeletingIds((prev) => new Set(prev).add(serverId)) try { await deleteServer(workspaceId, serverId) - await refreshTools(true) + await refreshTools() if (selectedServerId === serverId) { handleSelectServer(null) } diff --git a/apps/tradinggoose/widgets/widgets/list_skill/components/skill-create-menu.tsx b/apps/tradinggoose/widgets/widgets/list_skill/components/skill-create-menu.tsx index 4ad563bf3..b9902c970 100644 --- a/apps/tradinggoose/widgets/widgets/list_skill/components/skill-create-menu.tsx +++ b/apps/tradinggoose/widgets/widgets/list_skill/components/skill-create-menu.tsx @@ -2,7 +2,7 @@ import { type ChangeEvent, useCallback, useRef } from 'react' import { Plus, Upload } from 'lucide-react' -import { useLocale } from 'next-intl' +import { useLocale, useMessages } from 'next-intl' import { DropdownMenu, DropdownMenuContent, @@ -10,8 +10,6 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { useMessages } from 'next-intl' -import { cn } from '@/lib/utils' import { widgetHeaderIconButtonClassName, widgetHeaderMenuContentClassName, @@ -19,6 +17,7 @@ import { widgetHeaderMenuItemClassName, widgetHeaderMenuTextClassName, } from '@/components/widget-header-control' +import { cn } from '@/lib/utils' interface SkillCreateMenuProps { disabled?: boolean diff --git a/apps/tradinggoose/widgets/widgets/list_skill/components/skill-list/skill-list.tsx b/apps/tradinggoose/widgets/widgets/list_skill/components/skill-list/skill-list.tsx index b84505984..623c770c4 100644 --- a/apps/tradinggoose/widgets/widgets/list_skill/components/skill-list/skill-list.tsx +++ b/apps/tradinggoose/widgets/widgets/list_skill/components/skill-list/skill-list.tsx @@ -1,12 +1,11 @@ 'use client' import { useCallback, useEffect, useMemo, useState } from 'react' -import { useLocale } from 'next-intl' +import { useLocale, useMessages } from 'next-intl' import { LoadingAgent } from '@/components/ui/loading-agent' import { SKILL_NAME_MAX_LENGTH } from '@/lib/skills/import-export' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useDeleteSkill, useSkills, useUpdateSkill } from '@/hooks/queries/skills' -import { useMessages } from 'next-intl' import { formatTemplate } from '@/i18n/utils' import { usePairColorContext, useSetPairColorContext } from '@/stores/dashboard/pair-store' import { useSkillsStore } from '@/stores/skills/store' diff --git a/apps/tradinggoose/widgets/widgets/list_skill/index.tsx b/apps/tradinggoose/widgets/widgets/list_skill/index.tsx index 39ceccd8f..4609f2141 100644 --- a/apps/tradinggoose/widgets/widgets/list_skill/index.tsx +++ b/apps/tradinggoose/widgets/widgets/list_skill/index.tsx @@ -2,8 +2,8 @@ import { useCallback } from 'react' import { ToolCase } from 'lucide-react' +import { useLocale, useMessages } from 'next-intl' import { widgetHeaderButtonGroupClassName } from '@/components/widget-header-control' -import { useLocale } from 'next-intl' import { parseImportedSkillsFile } from '@/lib/skills/import-export' import { useUserPermissionsContext, @@ -20,7 +20,6 @@ import { SKILL_EDITOR_WIDGET_KEY, SKILL_LIST_WIDGET_KEY, } from '@/widgets/widgets/_shared/skill/utils' -import { useMessages } from 'next-intl' import { SkillCreateMenu } from '@/widgets/widgets/list_skill/components/skill-create-menu' import { SkillList, diff --git a/apps/tradinggoose/widgets/widgets/list_workflow/components/workflow-create-menu.tsx b/apps/tradinggoose/widgets/widgets/list_workflow/components/workflow-create-menu.tsx index c3f361974..d4b3b0626 100644 --- a/apps/tradinggoose/widgets/widgets/list_workflow/components/workflow-create-menu.tsx +++ b/apps/tradinggoose/widgets/widgets/list_workflow/components/workflow-create-menu.tsx @@ -114,67 +114,28 @@ export function DashboardWorkflowCreateMenu({ .filter((workflow) => workflow.workspaceId === workspaceId) .map((workflow) => workflow.name) + let importedSkillsBySourceName: + | ReturnType + | undefined + if (parsedWorkflow.data.skills.length > 0) { const importResult = await importSkillsMutation.mutateAsync({ workspaceId, file: parsedFile, }) - const importedSkillsBySourceName = buildImportedWorkflowSkillsLookup({ + importedSkillsBySourceName = buildImportedWorkflowSkillsLookup({ expectedSkills: parsedWorkflow.data.skills, importedSkills: importResult?.importedSkills, }) - - const newWorkflowId = await importWorkflowFromJsonContent({ - content, - workspaceId, - existingWorkflowNames, - importedSkillsBySourceName, - createWorkflow, - persistWorkflowState: async (workflowId, state) => { - const response = await fetch(`/api/workflows/${workflowId}/state`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(state), - }) - - if (!response.ok) { - logger.error('Failed to persist imported workflow to database') - throw new Error('Failed to save workflow') - } - }, - }) - - logger.info('Workflow imported successfully from dashboard widget') - - if (newWorkflowId) { - onWorkflowCreated?.(newWorkflowId) - } - - return } const newWorkflowId = await importWorkflowFromJsonContent({ content, workspaceId, existingWorkflowNames, + importedSkillsBySourceName, createWorkflow, - persistWorkflowState: async (workflowId, state) => { - const response = await fetch(`/api/workflows/${workflowId}/state`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(state), - }) - - if (!response.ok) { - logger.error('Failed to persist imported workflow to database') - throw new Error('Failed to save workflow') - } - }, }) logger.info('Workflow imported successfully from dashboard widget') diff --git a/changelog/June-28-2026.md b/changelog/June-28-2026.md new file mode 100644 index 000000000..601cc87cc --- /dev/null +++ b/changelog/June-28-2026.md @@ -0,0 +1,91 @@ +# June-28-2026 + +## feat/copilot-mcp @ 933199d1 vs upstream/staging + +### Summary +- Adds a TradingGoose Copilot MCP surface for trusted personal coding agents, including JSON-RPC tool listing/calling at `apps/tradinggoose/app/api/copilot/mcp/route.ts`, local installer scripts at `apps/tradinggoose/app/mcp/[[...command]]/route.ts`, and browser-approved device login under `apps/tradinggoose/app/api/auth/mcp/*`. +- Moves Copilot entity, workflow, monitor, knowledge, credential, and MCP server tool execution behind the server tool router in `apps/tradinggoose/lib/copilot/tools/server/router.ts`, with review staging for Studio and full-access execution for authenticated MCP calls. +- Makes Yjs the required editable-state path for workflows and saved entities through `apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts`, `apps/tradinggoose/socket-server/routes/http.ts`, and the saved-entity helpers in `apps/tradinggoose/lib/yjs/server/apply-entity-state.ts`. +- Simplifies API-key authentication to current encrypted `sk-tradinggoose-...` keys in `apps/tradinggoose/lib/api-key/service.ts` and removes legacy plaintext/key migration behavior. + +### Branch Scope +- Compared `61dc75e4449ae1b9f7a9aaa55cc25bf0c46cfb71..933199d18350e4f5094e864ec0d45d7397381c49`; `61dc75e4` is both the merge base and the refreshed `origin/staging` commit in this checkout. +- The requested base was `upstream/staging`, but this clone has no `upstream` remote and no `refs/remotes/upstream/staging`; `git fetch upstream staging` failed with `fatal: 'upstream' does not appear to be a git repository`. The available staging comparison was therefore refreshed with `git fetch origin staging` and documented here against that concrete staging ref. +- `git status --short --untracked-files=all`, `git diff --stat HEAD`, and `git diff --name-status HEAD` were clean before this changelog file was created, so no pre-existing dirty feature edits were included despite the dirty-tree request. +- Main areas touched: Copilot MCP routes and auth, API-key storage and rate limiting, server-side Copilot tool routing, saved entity document contracts, workflow/Yjs persistence, MCP server UI/store/editor flows, workspace bootstrap, monitors, knowledge bases, deployment/runtime configuration, and focused tests. + +### Key Changes +- `apps/tradinggoose/app/api/copilot/mcp/route.ts` implements the external MCP JSON-RPC endpoint with `initialize`, `ping`, `tools/list`, `tools/call`, `resources/list`, and `prompts/list`, negotiates `2025-06-18` and `2025-03-26`, rejects oversized batches, and authenticates only personal API keys through `authenticateApiKeyFromHeader(..., { keyTypes: ['personal'] })`. +- `apps/tradinggoose/app/api/copilot/mcp/route.ts` exposes only IDs returned by `getMcpServerToolIds()` and dispatches calls through `routeExecution()` with `{ userId, accessLevel: 'full' }`, while `buildInstructions()` lists accessible workspaces and creates a default workspace through `createDefaultWorkspaceForUser()` when an MCP user has none. +- `apps/tradinggoose/app/mcp/[[...command]]/route.ts`, `apps/tradinggoose/lib/mcp/install-script.ts`, and `apps/tradinggoose/lib/mcp/local-config-writer-script.ts` add shell and PowerShell setup/login scripts for Codex, Cursor, Claude, and OpenCode. Local config stores the MCP URL plus `Authorization: Bearer `, not workspace or entity targets. +- `apps/tradinggoose/lib/mcp/auth.ts` owns the device-login contract: `startMcpDeviceLogin()`, `createMcpDeviceLoginApprovalChallenge()`, `pollMcpDeviceLogin()`, `acknowledgeMcpDeviceLogin()`, `approveMcpDeviceLogin()`, and `cancelMcpDeviceLogin()` store signed pending/approved/cancelled states in `verification` rows and issue personal API keys only after browser approval. +- `apps/tradinggoose/app/api/auth/mcp/start/route.ts`, `apps/tradinggoose/app/api/auth/mcp/poll/route.ts`, `apps/tradinggoose/app/api/auth/mcp/authorize/route.ts`, and `apps/tradinggoose/app/[locale]/(auth)/mcp/authorize/page.tsx` wrap the device login flow with public rate limits, API-key-storage availability checks, trusted form-origin validation, localized status copy, and login redirects that preserve locale. +- `apps/tradinggoose/lib/api-key/service.ts` makes the stored key shape `display:lookupDigest:iv:ciphertext:authTag`, validates only current `sk-tradinggoose-...` secrets, and requires a valid `API_ENCRYPTION_KEY`; `apps/tradinggoose/lib/api/rate-limit.ts` adds scoped limits for `copilot-mcp`, `copilot-mcp-public`, `mcp-auth-start`, and `mcp-auth-poll`. +- `apps/tradinggoose/lib/copilot/tools/server/router.ts` is the canonical server tool registry for workflows, saved entities, monitors, knowledge, Google Drive, credentials, environment variables, search, and MCP servers. It validates `ToolId`, parses `ServerToolArgSchemas`, merges `workspaceId` into `ServerToolExecutionContext` with `withWorkspaceArgContext()`, and checks workspace access before executing tools. +- `apps/tradinggoose/lib/copilot/tools/server/base-tool.ts` and `apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts` define review-safe mutation execution through `shouldStageServerToolMutationForReview()`, `hashServerToolReviewBase()`, `assertAcceptedServerToolReviewBase()`, `stageServerManagedToolReview()`, and `acceptServerManagedToolReview()`. Review tokens store encrypted payloads and are claimed before full-access execution. +- `apps/tradinggoose/lib/copilot/entity-documents.ts` defines the saved-entity document formats `tg-skill-document-v1`, `tg-custom-tool-document-v1`, `tg-indicator-document-v1`, `tg-mcp-server-document-v1`, `tg-knowledge-base-document-v1`, and `tg-workflow-variable-document-v1`, plus `normalizeEntityFields()`, `parseEntityDocument()`, `serializeEntityDocument()`, and `ENTITY_SECRET_PLACEHOLDER`. +- `apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts` centralizes saved-entity list/read/create/update behavior with `buildSavedEntityListInfo()`, `executeCreateEntityDocumentMutation()`, `executeUpdateEntityDocumentMutation()`, `readSavedEntityDocumentFields()`, `verifyWorkspaceContext()`, and `verifySavedEntityContext()`. Lists intentionally return only `entityId` and canonical `entityName`. +- `apps/tradinggoose/lib/copilot/tools/server/entities/mcp-server.ts` preserves `[redacted]` header/env placeholders on MCP server edits, rejects placeholders on new server creation, and applies edits through `applySavedEntityState()`. `apps/tradinggoose/app/api/mcp/servers/schema.ts` now allows disabled MCP server drafts without a URL but still requires a URL when `enabled !== false`. +- `apps/tradinggoose/lib/yjs/entity-session.ts` is the live saved-entity document contract: entity sessions own top-level `fields` and `metadata`, entity-list sessions own `members`, and MCP server list members carry `enabled`. `apps/tradinggoose/lib/yjs/entity-state.ts` maps saved DB rows into these fields and defines the `SavedEntityRealtimeRequiredError` contract. +- `apps/tradinggoose/lib/yjs/server/bootstrap-review-target.ts` reads workflow, saved-entity, and entity-list snapshots from the socket server, bootstraps missing sessions from canonical DB state, and intentionally maps unavailable saved-entity snapshots to `SavedEntityRealtimeRequiredError` instead of falling back to stale app-side data. +- `apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts` is the Next.js-to-socket bridge for snapshots, workflow patches, saved-entity applies, raw Yjs update applies, entity-list member notifications, and session deletion. `apps/tradinggoose/socket-server/routes/http.ts` exposes the corresponding internal endpoints and uses `applyThroughStaging()` so server-authored mutations persist a detached Yjs copy before updating the live collaborative doc. +- `apps/tradinggoose/lib/workflows/db-helpers.ts` adds `requireWorkflowRealtimeState()`, `WorkflowRealtimeRequiredError`, duplicate block/edge ID remapping, and `saveWorkflowYjsDocToDb()`. `apps/tradinggoose/app/api/workflows/[id]/route.ts` now reads editable workflow state from Yjs and maps bridge failures with `createWorkflowRealtimeRequiredResponse()` from `apps/tradinggoose/app/api/workflows/utils.ts`. +- `apps/tradinggoose/lib/workflows/validation.ts` keeps Agent custom tool selections on canonical runtime IDs by requiring `custom_` via `getCustomToolEntityIdFromRuntimeId()` and sanitizing invalid custom tool entries before normalized persistence. +- `apps/tradinggoose/lib/workspaces/service.ts` adds `createDefaultWorkspaceForUser()` with a PostgreSQL advisory transaction lock, and `apps/tradinggoose/app/[locale]/workspace/page.tsx` uses it to bootstrap a first workspace during root workspace entry. +- `apps/tradinggoose/stores/mcp-servers/store.ts`, `apps/tradinggoose/hooks/use-mcp-tools.ts`, and `apps/tradinggoose/widgets/widgets/editor_mcp/editor-mcp-body.tsx` update MCP server UI data flow: enabled/deleted state drives discovery, `MCP_TOOLS_CHANGED_EVENT` invalidates tool discovery, and the editor saves MCP server fields through `useSavedEntityYjsSession('mcp_server', ...)`. +- `apps/tradinggoose/app/api/monitors/update-service.ts` pulls monitor update behavior out of the route so `apps/tradinggoose/lib/copilot/tools/server/monitor/edit-monitor.ts` can reuse the same validation and persistence path for reviewed Copilot monitor edits. +- `apps/tradinggoose/lib/copilot/runtime-tool-manifest.ts` converts `ToolArgSchemas` into JSON schema for runtime tools and attaches semantic validators, while `apps/tradinggoose/lib/copilot/tools/client/server-tool-metadata.ts` is now the client-side display metadata surface for server-managed tool calls. + +### Design Decisions +- External MCP is a personal-token, full-access server-tool surface. Studio review tokens remain internal to the app review flow, while MCP callers receive sanitized tool results/errors through JSON-RPC `tools/call`. +- The MCP local config is intentionally target-agnostic. Workspace IDs, entity IDs, review targets, and document targets are supplied per tool call from `tools/list` schemas and list/read results, not persisted in local MCP client config. +- Saved entity mutation tools use full document replacement with explicit document format constants. This keeps skill, custom tool, indicator, MCP server, and knowledge base edits on one parse/normalize/serialize path instead of per-tool patch shapes. +- Yjs is the canonical editable-state transport for workflows and saved entities. Normalized tables seed and materialize Yjs state, but user-facing editable reads and server-authored mutations must go through the socket bridge so stale DB fallbacks do not overwrite live collaborative state. +- Saved-entity list tools are discovery surfaces only. They return IDs, canonical names, and MCP `enabled` state from live entity-list Yjs sessions instead of reintroducing per-entity detail mappers into list calls. +- Current API keys are not legacy-compatible. `apps/tradinggoose/lib/api-key/auth.ts` was removed so future auth work extends `apps/tradinggoose/lib/api-key/service.ts` instead of reviving plaintext or dual-format matching. + +### Shared Contracts and Helpers to Reuse +- Use `getMcpServerToolIds()` and `routeExecution()` from `apps/tradinggoose/lib/copilot/tools/server/router.ts` for server tool dispatch; do not create a second MCP-visible tool allowlist. +- Use `ServerToolExecutionContext`, `withWorkspaceArgContext()`, `throwIfServerToolAborted()`, `shouldStageServerToolMutationForReview()`, `hashServerToolReviewBase()`, and `assertAcceptedServerToolReviewBase()` from `apps/tradinggoose/lib/copilot/tools/server/base-tool.ts` for every server tool. +- Use `stageServerManagedToolReview()` and `acceptServerManagedToolReview()` from `apps/tradinggoose/lib/copilot/tools/server/review-acceptance.ts` for Studio-reviewed server mutations that must survive stale-base checks. +- Use `COPILOT_TOOL_IDS`, `ToolArgSchemas`, `ServerToolArgSchemas`, and `getCopilotRuntimeToolManifest()` from `apps/tradinggoose/lib/copilot/registry.ts` and `apps/tradinggoose/lib/copilot/runtime-tool-manifest.ts` as the canonical tool schema surface. +- Use the document constants and helpers in `apps/tradinggoose/lib/copilot/entity-documents.ts`: `ENTITY_DOCUMENT_FORMATS`, `normalizeEntityFields()`, `parseEntityDocument()`, `serializeEntityDocument()`, `getEntityDocumentName()`, and `ENTITY_SECRET_PLACEHOLDER`. +- Use `executeCreateEntityDocumentMutation()`, `executeUpdateEntityDocumentMutation()`, `buildSavedEntityListInfo()`, `readSavedEntityDocumentFields()`, `verifyWorkspaceContext()`, and `verifySavedEntityContext()` from `apps/tradinggoose/lib/copilot/tools/server/entities/shared.ts` when adding saved-entity Copilot tools. +- Use `applySavedEntityState()`, `saveSavedEntityYjsDocToDb()`, and `publishCreatedSavedEntityListMembers()` from `apps/tradinggoose/lib/yjs/server/apply-entity-state.ts` for saved-entity materialization and list synchronization. +- Use `getYjsSnapshot()`, `applyWorkflowPatchInSocketServer()`, `applyEntityStateInSocketServer()`, `applyYjsUpdateInSocketServer()`, `notifyEntityListMembersUpserted()`, `notifyEntityListMemberRemoved()`, and `deleteYjsSessionInSocketServer()` from `apps/tradinggoose/lib/yjs/server/snapshot-bridge.ts` instead of direct socket-server HTTP calls. +- Use `requireWorkflowRealtimeState()`, `WorkflowRealtimeRequiredError`, `saveWorkflowToNormalizedTables()`, and `saveWorkflowYjsDocToDb()` from `apps/tradinggoose/lib/workflows/db-helpers.ts` for workflow persistence/read behavior. +- Use `startMcpDeviceLogin()`, `pollMcpDeviceLogin()`, `acknowledgeMcpDeviceLogin()`, `createMcpDeviceLoginApprovalChallenge()`, `approveMcpDeviceLogin()`, and `cancelMcpDeviceLogin()` from `apps/tradinggoose/lib/mcp/auth.ts` for MCP login lifecycle work. +- Use `createApiKey()`, `authenticateApiKeyFromHeader()`, `isApiKeyStorageAvailable()`, `getStoredApiKey()`, and `storedApiKeyMatches()` from `apps/tradinggoose/lib/api-key/service.ts`; do not import or recreate removed API-key auth helpers. +- Use `createDefaultWorkspaceForUser()` from `apps/tradinggoose/lib/workspaces/service.ts` when a signed-in user or MCP-authenticated user needs an initial workspace. +- Use `MCP_TOOLS_CHANGED_EVENT`, `useMcpServersStore()`, and `useMcpTools()` for MCP server/tool UI cache behavior, and keep MCP server editing on `useSavedEntityYjsSession('mcp_server', ...)`. + +### Removed or Replaced Items +- Removed the client-side Copilot entity tool path under `apps/tradinggoose/lib/copilot/tools/client/entities/*`. Use `apps/tradinggoose/lib/copilot/tools/server/entities/*` and `entities/shared.ts` for saved entity list/read/create/edit/rename tools. +- Removed client-side workflow mutation/read tools under `apps/tradinggoose/lib/copilot/tools/client/workflow/*`. Use server workflow tools in `apps/tradinggoose/lib/copilot/tools/server/workflow/*`, `apps/tradinggoose/lib/copilot/tools/server/entities/workflow.ts`, and `apps/tradinggoose/lib/copilot/workflow/block-output-utils.ts`. +- Removed client-side monitor and knowledge tool files under `apps/tradinggoose/lib/copilot/tools/client/monitor/*` and `apps/tradinggoose/lib/copilot/tools/client/knowledge/knowledge-base.ts`. Use `apps/tradinggoose/lib/copilot/tools/server/monitor/*` and `apps/tradinggoose/lib/copilot/tools/server/knowledge/knowledge-base.ts`. +- Removed `apps/tradinggoose/app/api/workflows/[id]/state/route.ts` and its test. Do not restore a Next.js full-state save route; editable workflow state should flow through `applyWorkflowState()`, `applyWorkflowPatchInSocketServer()`, and the internal socket-server apply endpoint. +- Removed `apps/tradinggoose/lib/api-key/auth.ts`. Do not reintroduce legacy plaintext or dual-format API-key authentication; extend `apps/tradinggoose/lib/api-key/service.ts`. +- Removed `apps/tradinggoose/lib/workflows/custom-tools-persistence.ts`. Do not extract and upsert custom tools from workflow state on save; custom tools are saved entities and Agent blocks should retain canonical `custom_` references. +- Removed `apps/tradinggoose/socket-server/yjs/persistence.ts` and the old persistence-specific upstream-utils test. Do not bring back Redis/local TTL Yjs session blobs; the socket server owns live docs in `apps/tradinggoose/socket-server/yjs/upstream-utils.ts` and materializes through internal bridge endpoints. +- Renamed `apps/tradinggoose/lib/copilot/tools/client/monitor/monitor-tool-utils.ts` to `apps/tradinggoose/lib/copilot/tools/server/monitor/shared.ts`; use the server shared monitor envelope helpers there. +- Moved `apps/tradinggoose/lib/copilot/tools/client/workflow/block-output-utils.ts` to `apps/tradinggoose/lib/copilot/workflow/block-output-utils.ts`; import the neutral workflow helper path from new server/client code. +- No project `*/migration/*` files were part of this branch delta. + +### Future Branch Guardrails +- Do not add new MCP-exposed tools by bypassing `getMcpServerToolIds()`; add the tool to the server router and registry schema so both Studio and MCP surfaces share validation. +- Do not store or infer `workspaceId`, `entityId`, or review targets in local MCP config files. Keep local config limited to the MCP URL and bearer token. +- Do not make external MCP auth accept workspace keys or session cookies. The JSON-RPC endpoint expects personal API keys issued by the MCP device login flow. +- Do not revive app-side DB fallbacks for editable workflow or saved-entity reads. Bridge or realtime failures should surface as `WORKFLOW_REALTIME_REQUIRED` or `SAVED_ENTITY_REALTIME_REQUIRED`. +- Do not reintroduce `/api/workflows/[id]/state` or any route that directly writes full workflow editable state outside the Yjs socket apply path. +- Do not expose MCP server secrets in Copilot documents. Preserve `[redacted]` through `preserveMcpServerSecretPlaceholders()` and reject placeholders on new MCP server values. +- Do not create per-entity list implementations that return full documents. Use entity-list Yjs sessions and reserve detail reads for `read_*` tools. +- Do not accept custom tool schema `function.name` as identity. Saved custom tools use document `title` for display and `custom_` runtime IDs for workflow/tool references. +- Do not introduce legacy API-key support, migration backfill, or plaintext key matching. API-key access is unavailable unless `API_ENCRYPTION_KEY` satisfies the current storage contract. + +### Validation Notes +- Followed the requested `staging-changelog` workflow and `changelog/TEMPLATE.md`, with the explicit user-requested base label of `upstream/staging`. +- Reviewed `git status --short --untracked-files=all`, `git remote -v`, `git fetch upstream staging`, `git show-ref --verify refs/remotes/upstream/staging`, `git show-ref --verify refs/remotes/origin/staging`, `git fetch origin staging`, `git merge-base origin/staging feat/copilot-mcp`, `git log --oneline 61dc75e4..feat/copilot-mcp`, `git diff --stat`, `git diff --name-status --find-renames`, `git diff --summary`, `git diff --dirstat`, `git diff --stat HEAD`, and `git diff --name-status HEAD`. +- Confirmed `upstream/staging` cannot be fetched in this clone because no `upstream` remote is configured; the concrete comparison used refreshed `origin/staging` at `61dc75e4`. +- Inspected `AGENTS.md`, `changelog/TEMPLATE.md`, existing changelog style, package scripts in `package.json`, Copilot MCP route/tests, MCP auth routes/page/service/tests, installer/config writer scripts/tests, API-key service/tests, rate limiting, server tool router/base/review helpers/tests, runtime manifest/registry, saved entity document helpers/tests, Yjs entity/session/apply/bootstrap/snapshot bridge tests, socket-server internal Yjs routes/upstream utils/tests, workflow route/db helpers/validation/tests, MCP server schema/service/store/hook/editor tests, monitor update service/tool tests, knowledge server tools, workspace bootstrap, and deleted paths from the merge base. +- Confirmed there were no pre-existing unstaged or staged feature changes before this changelog update and no changed project `*/migration/*` files in the branch diff. +- No automated test suite was run for this changelog-only update; validation focused on merge-base diff review, related source/test inspection, dirty-tree confirmation, and template conformance. diff --git a/docker-compose.ollama.yml b/docker-compose.ollama.yml index ddc91e53d..dac58c390 100644 --- a/docker-compose.ollama.yml +++ b/docker-compose.ollama.yml @@ -21,13 +21,13 @@ services: - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-http://localhost:3000} - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in .env} - ENCRYPTION_KEY=${ENCRYPTION_KEY:?set ENCRYPTION_KEY in .env} + - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-} - INTERNAL_API_SECRET=${INTERNAL_API_SECRET:?set INTERNAL_API_SECRET in .env} - REDIS_URL=redis://redis:6379 - COPILOT_API_KEY=${COPILOT_API_KEY:-} - COPILOT_API_URL=${COPILOT_API_URL:-} - OLLAMA_URL=http://ollama:11434 - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:-http://localhost:3002} - - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-} depends_on: redis: condition: service_healthy diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index fc0c62a5e..b80653d2b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -18,13 +18,13 @@ services: - NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:?set NEXT_PUBLIC_APP_URL in .env} - BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in .env} - ENCRYPTION_KEY=${ENCRYPTION_KEY:?set ENCRYPTION_KEY in .env} + - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-} - INTERNAL_API_SECRET=${INTERNAL_API_SECRET:?set INTERNAL_API_SECRET in .env} - REDIS_URL=redis://redis:6379 - COPILOT_API_KEY=${COPILOT_API_KEY:-} - COPILOT_API_URL=${COPILOT_API_URL:-} - OLLAMA_URL=${OLLAMA_URL:-http://localhost:11434} - NEXT_PUBLIC_SOCKET_URL=${NEXT_PUBLIC_SOCKET_URL:?set NEXT_PUBLIC_SOCKET_URL in .env} - - API_ENCRYPTION_KEY=${API_ENCRYPTION_KEY:-} depends_on: redis: condition: service_healthy diff --git a/helm/tradinggoose/README.md b/helm/tradinggoose/README.md index c2e561344..4f267950b 100644 --- a/helm/tradinggoose/README.md +++ b/helm/tradinggoose/README.md @@ -641,7 +641,7 @@ For production deployments, make sure to: **Optional Security (Recommended for Production):** - `CRON_SECRET`: Authenticates scheduled job requests to API endpoints (required only if `cronjobs.enabled=true`) -- `API_ENCRYPTION_KEY`: Encrypts API keys at rest in database (must be exactly 64 hex characters). If not set, API keys are stored in plain text. Generate using: `openssl rand -hex 32` (outputs 64 hex chars representing 32 bytes) +- `API_ENCRYPTION_KEY`: Required for API-key access and MCP token issuance; encrypts API keys at rest in database (must be exactly 64 hex characters). Generate using: `openssl rand -hex 32` ### Example secure values: @@ -652,7 +652,7 @@ app: ENCRYPTION_KEY: "your-secure-encryption-key-here" INTERNAL_API_SECRET: "your-secure-internal-api-secret-here" CRON_SECRET: "your-secure-cron-secret-here" - API_ENCRYPTION_KEY: "your-64-char-hex-string-for-api-key-encryption" # Optional but recommended + API_ENCRYPTION_KEY: "your-64-char-hex-string-for-api-key-encryption" postgresql: auth: @@ -704,4 +704,4 @@ kubectl logs job/-migrations - Documentation: https://docs.tradinggoose.ai - GitHub Issues: https://github.com/TradingGoose/TradingGoose-Studio/issues -- Discord: https://discord.gg/wavf5JWhuT \ No newline at end of file +- Discord: https://discord.gg/wavf5JWhuT diff --git a/helm/tradinggoose/examples/values-aws.yaml b/helm/tradinggoose/examples/values-aws.yaml index f4472ea87..c8a6c240a 100644 --- a/helm/tradinggoose/examples/values-aws.yaml +++ b/helm/tradinggoose/examples/values-aws.yaml @@ -37,9 +37,9 @@ app: INTERNAL_API_SECRET: "your-secure-production-internal-api-secret-here" CRON_SECRET: "your-secure-production-cron-secret-here" - # Optional: API Key Encryption (RECOMMENDED for production) + # API Key Encryption # Generate 64-character hex string using: openssl rand -hex 32 - API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" # Optional but recommended + API_ENCRYPTION_KEY: "" NODE_ENV: "production" NEXT_TELEMETRY_DISABLED: "1" diff --git a/helm/tradinggoose/examples/values-azure.yaml b/helm/tradinggoose/examples/values-azure.yaml index bab801160..7edb41d3a 100644 --- a/helm/tradinggoose/examples/values-azure.yaml +++ b/helm/tradinggoose/examples/values-azure.yaml @@ -35,9 +35,9 @@ app: INTERNAL_API_SECRET: "your-secure-production-internal-api-secret-here" CRON_SECRET: "your-secure-production-cron-secret-here" - # Optional: API Key Encryption (RECOMMENDED for production) + # API Key Encryption # Generate 64-character hex string using: openssl rand -hex 32 - API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" # Optional but recommended + API_ENCRYPTION_KEY: "" NODE_ENV: "production" NEXT_TELEMETRY_DISABLED: "1" diff --git a/helm/tradinggoose/examples/values-development.yaml b/helm/tradinggoose/examples/values-development.yaml index 7572dd7e2..2e489a074 100644 --- a/helm/tradinggoose/examples/values-development.yaml +++ b/helm/tradinggoose/examples/values-development.yaml @@ -32,9 +32,9 @@ app: INTERNAL_API_SECRET: "dev-32-char-internal-secret-not-secure" CRON_SECRET: "dev-32-char-cron-secret-not-for-prod" - # Optional: API Key Encryption (leave empty for dev, encrypts API keys at rest) - # For production, generate 64-char hex using: openssl rand -hex 32 - API_ENCRYPTION_KEY: "" # Optional - if not set, API keys stored in plain text + # API Key Encryption + # Generate 64-character hex string using: openssl rand -hex 32 + API_ENCRYPTION_KEY: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" # Realtime service realtime: diff --git a/helm/tradinggoose/examples/values-external-db.yaml b/helm/tradinggoose/examples/values-external-db.yaml index a4a79d351..db51346b9 100644 --- a/helm/tradinggoose/examples/values-external-db.yaml +++ b/helm/tradinggoose/examples/values-external-db.yaml @@ -31,9 +31,9 @@ app: INTERNAL_API_SECRET: "" # Set via --set flag or external secret manager CRON_SECRET: "" # Set via --set flag or external secret manager - # Optional: API Key Encryption (RECOMMENDED for production) + # API Key Encryption # Generate 64-character hex string using: openssl rand -hex 32 - API_ENCRYPTION_KEY: "" # Optional but recommended - encrypts API keys at rest + API_ENCRYPTION_KEY: "" # Optional unless API-key access or MCP token issuance is used NODE_ENV: "production" NEXT_TELEMETRY_DISABLED: "1" diff --git a/helm/tradinggoose/examples/values-gcp.yaml b/helm/tradinggoose/examples/values-gcp.yaml index 2746abea5..4aebb63d7 100644 --- a/helm/tradinggoose/examples/values-gcp.yaml +++ b/helm/tradinggoose/examples/values-gcp.yaml @@ -37,9 +37,9 @@ app: INTERNAL_API_SECRET: "your-secure-production-internal-api-secret-here" CRON_SECRET: "your-secure-production-cron-secret-here" - # Optional: API Key Encryption (RECOMMENDED for production) + # API Key Encryption # Generate 64-character hex string using: openssl rand -hex 32 - API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" # Optional but recommended + API_ENCRYPTION_KEY: "" NODE_ENV: "production" NEXT_TELEMETRY_DISABLED: "1" diff --git a/helm/tradinggoose/examples/values-production.yaml b/helm/tradinggoose/examples/values-production.yaml index 21319e423..829520969 100644 --- a/helm/tradinggoose/examples/values-production.yaml +++ b/helm/tradinggoose/examples/values-production.yaml @@ -32,9 +32,9 @@ app: INTERNAL_API_SECRET: "your-production-internal-api-secret-here" CRON_SECRET: "your-production-cron-secret-here" - # Optional: API Key Encryption (RECOMMENDED for production) + # API Key Encryption # Generate 64-character hex string using: openssl rand -hex 32 - API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" # Optional but recommended + API_ENCRYPTION_KEY: "" # Email verification (set to true if you want to require email verification) EMAIL_VERIFICATION_ENABLED: "false" diff --git a/helm/tradinggoose/examples/values-whitelabeled.yaml b/helm/tradinggoose/examples/values-whitelabeled.yaml index cc07a0dda..5226982fd 100644 --- a/helm/tradinggoose/examples/values-whitelabeled.yaml +++ b/helm/tradinggoose/examples/values-whitelabeled.yaml @@ -25,9 +25,9 @@ app: INTERNAL_API_SECRET: "your-production-internal-api-secret-here" CRON_SECRET: "your-production-cron-secret-here" - # Optional: API Key Encryption (RECOMMENDED for production) + # API Key Encryption # Generate 64-character hex string using: openssl rand -hex 32 - API_ENCRYPTION_KEY: "your-64-char-hex-api-encryption-key-here" # Optional but recommended + API_ENCRYPTION_KEY: "" # UI Branding & Whitelabeling Configuration NEXT_PUBLIC_BRAND_NAME: "Acme AI Studio" diff --git a/helm/tradinggoose/templates/NOTES.txt b/helm/tradinggoose/templates/NOTES.txt index 65f2d43b9..b86df8aea 100644 --- a/helm/tradinggoose/templates/NOTES.txt +++ b/helm/tradinggoose/templates/NOTES.txt @@ -45,7 +45,7 @@ WARNING: You have disabled the internal PostgreSQL database. Make sure to configure an external database connection in your values.yaml file. {{- end }} -{{- if not .Values.app.env.BETTER_AUTH_SECRET }} +{{- if or (not .Values.app.env.BETTER_AUTH_SECRET) (not .Values.app.env.ENCRYPTION_KEY) }} ⚠️ SECURITY WARNING: Required secrets are not configured! @@ -64,4 +64,4 @@ Generate secure secrets using: For more information and configuration options, see: - Chart documentation: https://github.com/TradingGoose/TradingGoose-Studio/tree/main/helm/tradinggoose -- TradingGoose Documentation: https://docs.tradinggoose.ai \ No newline at end of file +- TradingGoose Documentation: https://docs.tradinggoose.ai diff --git a/helm/tradinggoose/templates/_helpers.tpl b/helm/tradinggoose/templates/_helpers.tpl index ac926fd74..615f95f5f 100644 --- a/helm/tradinggoose/templates/_helpers.tpl +++ b/helm/tradinggoose/templates/_helpers.tpl @@ -195,6 +195,9 @@ Validate required secrets and reject default placeholder values {{- if and .Values.app.enabled (eq .Values.app.env.ENCRYPTION_KEY "CHANGE-ME-32-CHAR-ENCRYPTION-KEY-FOR-PROD") }} {{- fail "app.env.ENCRYPTION_KEY must not use the default placeholder value. Generate a secure key with: openssl rand -hex 32" }} {{- end }} +{{- if and .Values.app.enabled .Values.app.env.API_ENCRYPTION_KEY (not (regexMatch "^[a-fA-F0-9]{64}$" .Values.app.env.API_ENCRYPTION_KEY)) }} +{{- fail "app.env.API_ENCRYPTION_KEY must be exactly 64 hex characters. Generate it with: openssl rand -hex 32" }} +{{- end }} {{- if and .Values.realtime.enabled (eq .Values.realtime.env.BETTER_AUTH_SECRET "CHANGE-ME-32-CHAR-SECRET-FOR-PRODUCTION-USE") }} {{- fail "realtime.env.BETTER_AUTH_SECRET must not use the default placeholder value. Generate a secure secret with: openssl rand -hex 32" }} {{- end }} diff --git a/helm/tradinggoose/values.schema.json b/helm/tradinggoose/values.schema.json index f7350ab01..d1573a4d4 100644 --- a/helm/tradinggoose/values.schema.json +++ b/helm/tradinggoose/values.schema.json @@ -94,6 +94,11 @@ "minLength": 32, "description": "Encryption key (minimum 32 characters required)" }, + "API_ENCRYPTION_KEY": { + "type": "string", + "pattern": "^$|^[a-fA-F0-9]{64}$", + "description": "Dedicated API-key encryption key; required only when API-key access or MCP token issuance is used" + }, "NEXT_PUBLIC_APP_URL": { "type": "string", "format": "uri", diff --git a/helm/tradinggoose/values.yaml b/helm/tradinggoose/values.yaml index b5432b470..7f1cffea5 100644 --- a/helm/tradinggoose/values.yaml +++ b/helm/tradinggoose/values.yaml @@ -68,10 +68,11 @@ app: # Generate using: openssl rand -hex 32 CRON_SECRET: "" # OPTIONAL - required only if cronjobs.enabled=true, authenticates scheduled job requests - # Optional: API Key Encryption (RECOMMENDED for production) + # API Key Encryption + # Required only when API-key access or MCP token issuance is used. # Generate 64-character hex string using: openssl rand -hex 32 (outputs 64 hex chars = 32 bytes) - API_ENCRYPTION_KEY: "" # OPTIONAL - encrypts API keys at rest, must be exactly 64 hex characters, if not set keys stored in plain text - + API_ENCRYPTION_KEY: "" + # Email & Communication EMAIL_VERIFICATION_ENABLED: "false" # Enable email verification for user registration and login (defaults to false) RESEND_API_KEY: "" # Resend API key for transactional emails diff --git a/packages/db/schema/workspaces.ts b/packages/db/schema/workspaces.ts index 2c94806af..b4dec8a00 100644 --- a/packages/db/schema/workspaces.ts +++ b/packages/db/schema/workspaces.ts @@ -86,7 +86,7 @@ export const apiKey = pgTable( expiresAt: timestamp('expires_at'), }, (table) => ({ - // Ensure workspace keys have a workspace_id and personal keys don't + // Ensure only workspace keys have a workspace_id. workspaceTypeCheck: check( 'workspace_type_check', sql`(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)`