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 6e0e3fa37..b0b75a108 100644 --- a/apps/tradinggoose/.env.example +++ b/apps/tradinggoose/.env.example @@ -12,8 +12,7 @@ # - Configure system-managed integration OAuth apps in Admin Integrations instead: # google-drive, google-email, github-repo, microsoft, slack, reddit, etc. # - Platform-managed vars like NODE_ENV, NEXT_RUNTIME, and VERCEL are omitted on purpose. -# - Internal React Email preview vars like EMAILS_DIR_ABSOLUTE_PATH and -# PREVIEW_SERVER_LOCATION are also omitted on purpose. +# - Internal React Email preview vars are omitted on purpose. # # Notes: # - Boolean-like flags should use true/false unless noted otherwise. @@ -44,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. @@ -232,14 +231,6 @@ KB_CONFIG_DELAY_BETWEEN_DOCUMENTS="50" # NEXT_PUBLIC_POSTHOG_KEY="" NEXT_PUBLIC_POSTHOG_DISABLED="1" -############################################################################### -# Email preview / local tooling -############################################################################### - -# Optional: only used by React Email preview and similar local tooling when -# NEXT_PUBLIC_APP_URL is not available in that process. -# EMAILS_PREVIEW_BASE_URL="http://localhost:3000" - ############################################################################### # Deployment and local infrastructure helpers ############################################################################### @@ -255,6 +246,3 @@ NEXT_PUBLIC_POSTHOG_DISABLED="1" # Optional: tell the app it is running inside a Docker image build. DOCKER_BUILD="false" - -# Optional: landing-page preview/dev helper used by getBaseUrl(). -# NEXT_PUBLIC_IS_PREVIEW_DEVELOPMENT="false" 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/(auth)/auth-locale-redirects.test.tsx b/apps/tradinggoose/app/(auth)/auth-locale-redirects.test.tsx index 3d7499e60..e2e29dacf 100644 --- a/apps/tradinggoose/app/(auth)/auth-locale-redirects.test.tsx +++ b/apps/tradinggoose/app/(auth)/auth-locale-redirects.test.tsx @@ -15,6 +15,7 @@ import { VerifyContent } from './verify/verify-content' const mockPush = vi.hoisted(() => vi.fn()) const mockSignUpEmail = vi.hoisted(() => vi.fn()) const mockSignInEmail = vi.hoisted(() => vi.fn()) +const mockSignOut = vi.hoisted(() => vi.fn()) const mockSendVerificationOtp = vi.hoisted(() => vi.fn()) const mockRefetchSession = vi.hoisted(() => vi.fn()) const mockUseVerification = vi.hoisted(() => vi.fn()) @@ -45,6 +46,7 @@ vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push: mockPush, }), + usePathname: () => '/login', })) vi.mock('@/lib/auth-client', () => ({ @@ -55,6 +57,7 @@ vi.mock('@/lib/auth-client', () => ({ signIn: { email: mockSignInEmail, }, + signOut: mockSignOut, emailOtp: { sendVerificationOtp: mockSendVerificationOtp, }, @@ -107,7 +110,11 @@ vi.mock('@/components/ui/label', () => ({ ...props }: React.LabelHTMLAttributes & { children?: React.ReactNode - }) => , + }) => ( + + ), })) vi.mock('@/components/ui/dialog', () => ({ @@ -149,6 +156,7 @@ describe('auth locale redirects', () => { mockPush.mockReset() mockSignUpEmail.mockReset() mockSignInEmail.mockReset() + mockSignOut.mockReset() mockSendVerificationOtp.mockReset() mockRefetchSession.mockReset() mockUseVerification.mockReset() @@ -162,6 +170,7 @@ describe('auth locale redirects', () => { root.unmount() }) container.remove() + vi.useRealTimers() vi.restoreAllMocks() reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = false }) @@ -204,6 +213,18 @@ describe('auth locale redirects', () => { }) } + async function renderLogin(locale: 'en' | 'es' | 'zh' = 'en') { + await renderWithLocale( + locale, + + ) + } + it.each(['es', 'zh'] as const)( 'pushes the canonical verify path after signup for %s', async (locale) => { @@ -226,6 +247,8 @@ describe('auth locale redirects', () => { await setInputValue('#password', 'Password1!') await submitRenderedForm() + expect(mockRefetchSession).not.toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() expect(mockPush).toHaveBeenCalledWith('/verify?fromSignup=true') } ) @@ -235,15 +258,7 @@ describe('auth locale redirects', () => { async (locale) => { mockSignInEmail.mockRejectedValue({ code: 'EMAIL_NOT_VERIFIED' }) - await renderWithLocale( - locale, - - ) + await renderLogin(locale) await setInputValue('#email', 'ada@example.com') await setInputValue('#password', 'Password1!') @@ -253,6 +268,75 @@ describe('auth locale redirects', () => { } ) + it('runs reauth cleanup on arrival and waits before direct login starts', async () => { + vi.useFakeTimers() + testState.searchParams = new URLSearchParams('reauth=1&callbackUrl=%2Fworkspace') + const cleanupSignalRef: { current: AbortSignal | null } = { current: null } + mockSignOut.mockImplementation((options) => { + cleanupSignalRef.current = options?.fetchOptions?.signal ?? null + return new Promise(() => {}) + }) + mockSignInEmail.mockResolvedValue({}) + + await renderLogin() + + expect(mockSignOut).toHaveBeenCalledTimes(1) + expect(container.querySelector('form')).toBeInstanceOf(HTMLFormElement) + + await setInputValue('#email', 'ada@example.com') + await setInputValue('#password', 'Password1!') + + await submitRenderedForm() + + expect(mockSignInEmail).not.toHaveBeenCalled() + + await act(async () => { + await vi.runOnlyPendingTimersAsync() + await Promise.resolve() + }) + + expect(cleanupSignalRef.current?.aborted).toBe(true) + expect(mockSignInEmail).toHaveBeenCalledTimes(1) + }) + + it.each([ + 'FAILED_TO_CREATE_SESSION', + 'UNABLE_TO_CREATE_SESSION', + 'FAILED_TO_GET_SESSION', + 'SESSION_EXPIRED', + ])('runs reauth cleanup when direct login returns %s', async (errorCode) => { + mockSignInEmail.mockResolvedValue({ error: { code: errorCode } }) + mockSignOut.mockReturnValue(new Promise(() => {})) + + await renderLogin() + + await setInputValue('#email', 'ada@example.com') + await setInputValue('#password', 'Password1!') + await submitRenderedForm() + + expect(mockSignInEmail).toHaveBeenCalledTimes(1) + expect(mockSignOut).toHaveBeenCalledTimes(1) + expect(container.textContent).toContain(getPublicCopy('en').auth.login.errors.unableToSignInNow) + }) + + it('keeps invalid credential failures on the login form', async () => { + mockSignInEmail.mockResolvedValue({ + error: { code: 'INVALID_CREDENTIALS', status: 401 }, + }) + + await renderLogin() + + await setInputValue('#email', 'ada@example.com') + await setInputValue('#password', 'wrong-password') + await submitRenderedForm() + + expect(mockSignOut).not.toHaveBeenCalled() + expect(container.querySelector('form')).toBeInstanceOf(HTMLFormElement) + expect(container.textContent).toContain( + getPublicCopy('en').auth.login.errors.invalidCredentials + ) + }) + it('pushes the canonical signup path from the verify screen back action', async () => { mockUseVerification.mockReturnValue({ otp: '', @@ -272,11 +356,7 @@ describe('auth locale redirects', () => { await renderWithLocale( 'en', - + ) const backButton = Array.from(container.querySelectorAll('button')).find( diff --git a/apps/tradinggoose/app/(auth)/auth-provider-callbacks.test.tsx b/apps/tradinggoose/app/(auth)/auth-provider-callbacks.test.tsx new file mode 100644 index 000000000..4fa491029 --- /dev/null +++ b/apps/tradinggoose/app/(auth)/auth-provider-callbacks.test.tsx @@ -0,0 +1,198 @@ +/** + * @vitest-environment jsdom + */ + +import type React from 'react' +import { act } from 'react' +import { NextIntlClientProvider } from 'next-intl' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getAuthErrorCallbackPath } from '@/lib/auth/auth-error-copy' +import { getPublicCopy } from '@/i18n/public-copy' +import { SocialLoginButtons } from './components/social-login-buttons' +import SSOForm from './sso/sso-form' + +const mockSocialSignIn = vi.hoisted(() => vi.fn()) +const mockSsoSignIn = vi.hoisted(() => vi.fn()) +const testState = vi.hoisted(() => ({ + searchParams: new URLSearchParams(), +})) + +vi.mock('next/navigation', () => ({ + useSearchParams: () => ({ + get: (key: string) => testState.searchParams.get(key), + }), +})) + +vi.mock('@/i18n/navigation', () => ({ + Link: ({ + children, + href, + ...props + }: React.AnchorHTMLAttributes & { + children?: React.ReactNode + href: string + }) => ( + + {children} + + ), +})) + +vi.mock('@/lib/auth-client', () => ({ + client: { + signIn: { + social: mockSocialSignIn, + sso: mockSsoSignIn, + }, + }, +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ + children, + ...props + }: React.ButtonHTMLAttributes & { + children?: React.ReactNode + }) => , +})) + +vi.mock('@/components/ui/input', () => ({ + Input: (props: React.InputHTMLAttributes) => , +})) + +vi.mock('@/components/ui/label', () => ({ + Label: ({ + children, + ...props + }: React.LabelHTMLAttributes & { + children?: React.ReactNode + }) => ( + + ), +})) + +vi.mock('@/components/ui/alert', () => ({ + Alert: ({ children }: { children?: React.ReactNode }) =>
{children}
, + AlertDescription: ({ children }: { children?: React.ReactNode }) =>
{children}
, +})) + +vi.mock('@/components/icons/icons', () => ({ + GithubIcon: () => , + GoogleIcon: () => , +})) + +vi.mock('@/app/(auth)/components/auth-page-header', () => ({ + AuthPageHeader: () => null, +})) + +vi.mock('@/app/(auth)/components/auth-waitlist-note', () => ({ + AuthWaitlistNote: () => null, +})) + +vi.mock('@/app/fonts/inter', () => ({ + inter: { className: '' }, +})) + +describe('auth provider callback routing', () => { + let container: HTMLDivElement + let root: Root + const reactActEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean + } + + beforeEach(() => { + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true + testState.searchParams = new URLSearchParams() + mockSocialSignIn.mockResolvedValue({}) + mockSsoSignIn.mockResolvedValue({}) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => { + root.unmount() + }) + container.remove() + vi.clearAllMocks() + reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = false + }) + + it.each([ + ['Google', 'google'], + ['GitHub', 'github'], + ])('routes %s OAuth callback failures to the auth error page', async (buttonText, provider) => { + await act(async () => { + root.render( + + + + ) + }) + + const button = Array.from(container.querySelectorAll('button')).find((candidate) => + candidate.textContent?.includes(buttonText) + ) + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Expected ${buttonText} button to render`) + } + + await act(async () => { + button.click() + }) + + expect(mockSocialSignIn).toHaveBeenCalledWith({ + provider, + callbackURL: '/workspace', + errorCallbackURL: getAuthErrorCallbackPath('/workspace'), + }) + }) + + it('routes SSO callback failures to the auth error page', async () => { + testState.searchParams = new URLSearchParams({ callbackUrl: '/workspace' }) + + await act(async () => { + root.render( + + + + ) + }) + + const input = container.querySelector('input[name="email"]') + if (!(input instanceof HTMLInputElement)) { + throw new Error('Expected SSO email input to render') + } + + const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set + valueSetter?.call(input, 'user@example.com') + + await act(async () => { + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + + const form = container.querySelector('form') + if (!(form instanceof HTMLFormElement)) { + throw new Error('Expected SSO form to render') + } + + await act(async () => { + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) + }) + + expect(mockSsoSignIn).toHaveBeenCalledWith({ + email: 'user@example.com', + callbackURL: '/workspace', + errorCallbackURL: getAuthErrorCallbackPath('/workspace'), + }) + }) +}) diff --git a/apps/tradinggoose/app/(auth)/components/social-login-buttons.tsx b/apps/tradinggoose/app/(auth)/components/social-login-buttons.tsx index 2bcfeddbf..c7cc886f4 100644 --- a/apps/tradinggoose/app/(auth)/components/social-login-buttons.tsx +++ b/apps/tradinggoose/app/(auth)/components/social-login-buttons.tsx @@ -1,6 +1,7 @@ 'use client' import { type ReactNode, useEffect, useState } from 'react' +import { useMessages } from 'next-intl' import { GithubIcon, GoogleIcon } from '@/components/icons/icons' import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' @@ -8,7 +9,6 @@ import { useAuthRedirectUrls } from '@/lib/auth/redirect-urls' import { client } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' import { inter } from '@/app/fonts/inter' -import { useMessages } from 'next-intl' import { formatTemplate } from '@/i18n/utils' const logger = createLogger('SocialLoginButtons') @@ -18,6 +18,7 @@ interface SocialLoginButtonsProps { googleAvailable: boolean callbackURL?: string isProduction: boolean + beforeSignIn?: () => Promise children?: ReactNode } @@ -26,6 +27,7 @@ export function SocialLoginButtons({ googleAvailable, callbackURL, isProduction: _isProduction, + beforeSignIn, children, }: SocialLoginButtonsProps) { const [isGithubLoading, setIsGithubLoading] = useState(false) @@ -36,6 +38,7 @@ export function SocialLoginButtons({ const copy = useMessages() const socialCopy = copy.auth.social const resolvedCallbackURL = authRedirectUrls.providerCallbackPath(callbackURL) + const errorCallbackURL = authRedirectUrls.providerErrorPath(resolvedCallbackURL) useEffect(() => { setMounted(true) @@ -82,9 +85,11 @@ export function SocialLoginButtons({ setIsGithubLoading(true) setErrorMessage('') try { + await beforeSignIn?.() const result = await client.signIn.social({ provider: 'github', callbackURL: resolvedCallbackURL, + errorCallbackURL, }) if (result?.error) { @@ -105,9 +110,11 @@ export function SocialLoginButtons({ setIsGoogleLoading(true) setErrorMessage('') try { + await beforeSignIn?.() const result = await client.signIn.social({ provider: 'google', callbackURL: resolvedCallbackURL, + errorCallbackURL, }) if (result?.error) { diff --git a/apps/tradinggoose/app/(auth)/components/sso-login-button.tsx b/apps/tradinggoose/app/(auth)/components/sso-login-button.tsx index dd2e79e67..e1fccf425 100644 --- a/apps/tradinggoose/app/(auth)/components/sso-login-button.tsx +++ b/apps/tradinggoose/app/(auth)/components/sso-login-button.tsx @@ -1,15 +1,16 @@ 'use client' +import { useMessages } from 'next-intl' import { Button } from '@/components/ui/button' import { getEnv, isTruthy } from '@/lib/env' import { cn } from '@/lib/utils' -import { useMessages } from 'next-intl' import { useRouter } from '@/i18n/navigation' import { normalizeCallbackUrl } from '@/i18n/utils' interface SSOLoginButtonProps { callbackURL?: string className?: string + beforeSignIn?: () => Promise // Visual variant for button styling and placement contexts // - 'primary' matches the main auth action button style // - 'outline' matches social provider buttons @@ -19,6 +20,7 @@ interface SSOLoginButtonProps { export function SSOLoginButton({ callbackURL, className, + beforeSignIn, variant = 'outline', }: SSOLoginButtonProps) { const router = useRouter() @@ -31,7 +33,8 @@ export function SSOLoginButton({ const resolvedCallbackURL = callbackURL ? normalizeCallbackUrl(callbackURL) : undefined - const handleSSOClick = () => { + const handleSSOClick = async () => { + await beforeSignIn?.() const ssoUrl = `/sso${ resolvedCallbackURL ? `?callbackUrl=${encodeURIComponent(resolvedCallbackURL)}` : '' }` diff --git a/apps/tradinggoose/app/(auth)/login/login-form.tsx b/apps/tradinggoose/app/(auth)/login/login-form.tsx index 226ff2c56..f207976cf 100644 --- a/apps/tradinggoose/app/(auth)/login/login-form.tsx +++ b/apps/tradinggoose/app/(auth)/login/login-form.tsx @@ -1,8 +1,9 @@ 'use client' -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Eye, EyeOff } from 'lucide-react' import { useSearchParams } from 'next/navigation' +import { useMessages } from 'next-intl' import { Button } from '@/components/ui/button' import { Dialog, @@ -13,8 +14,7 @@ import { } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { normalizeAuthErrorCode } from '@/lib/auth/auth-error-copy' -import { handleAuthError } from '@/lib/auth/auth-error-handler' +import { isSessionRecoveryAuthError, normalizeAuthErrorCode } from '@/lib/auth/auth-error-copy' import { useAuthRedirectUrls } from '@/lib/auth/redirect-urls' import { client } from '@/lib/auth-client' import { quickValidateEmail } from '@/lib/email/validation' @@ -27,11 +27,12 @@ import { AuthWaitlistNote } from '@/app/(auth)/components/auth-waitlist-note' import { SocialLoginButtons } from '@/app/(auth)/components/social-login-buttons' import { SSOLoginButton } from '@/app/(auth)/components/sso-login-button' import { inter } from '@/app/fonts/inter' -import { useMessages } from 'next-intl' import { Link, useRouter } from '@/i18n/navigation' import { normalizeCallbackUrl } from '@/i18n/utils' +import { clearUserData } from '@/stores' const logger = createLogger('LoginForm') +const REAUTH_CLEANUP_TIMEOUT_MS = 4000 const validateEmailField = ( emailValue: string, @@ -122,6 +123,50 @@ export default function LoginPage({ const [email, setEmail] = useState('') const [emailErrors, setEmailErrors] = useState([]) const [showEmailValidationError, setShowEmailValidationError] = useState(false) + const isReauth = searchParams.get('reauth') === '1' + const shouldRunReauthCleanupRef = useRef(isReauth) + const reauthCleanupPromiseRef = useRef | null>(null) + + const runReauthCleanup = useCallback(() => { + if (reauthCleanupPromiseRef.current) { + return reauthCleanupPromiseRef.current + } + + const abortController = new AbortController() + let timeoutId: ReturnType | null = null + const signOutPromise = client + .signOut({ fetchOptions: { signal: abortController.signal } }) + .then(() => undefined) + .catch((error) => { + if (!abortController.signal.aborted) { + logger.warn('Reauth sign-out failed', { error }) + } + }) + const timeoutPromise = new Promise((resolve) => { + timeoutId = setTimeout(() => { + abortController.abort() + resolve() + }, REAUTH_CLEANUP_TIMEOUT_MS) + }) + + const cleanupPromise = Promise.race([signOutPromise, timeoutPromise]).finally(async () => { + if (timeoutId) { + clearTimeout(timeoutId) + } + await clearUserData() + shouldRunReauthCleanupRef.current = false + reauthCleanupPromiseRef.current = null + }) + + reauthCleanupPromiseRef.current = cleanupPromise + return cleanupPromise + }, []) + + const prepareAuthStart = useCallback(async () => { + if (shouldRunReauthCleanupRef.current || reauthCleanupPromiseRef.current) { + await runReauthCleanup() + } + }, [runReauthCleanup]) useEffect(() => { if (searchParams) { @@ -144,6 +189,13 @@ export default function LoginPage({ } }, [searchParams]) + useEffect(() => { + shouldRunReauthCleanupRef.current = isReauth + if (isReauth) { + void runReauthCleanup() + } + }, [isReauth, runReauthCleanup]) + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Enter' && forgotPasswordOpen) { @@ -181,6 +233,17 @@ export default function LoginPage({ setShowValidationError(false) } + const isSessionRecoveryError = (error: any) => + [ + error?.code, + error?.error, + error?.message, + error?.response?.data?.error, + error?.response?.data?.message, + ].some((value) => { + return isSessionRecoveryAuthError(value) + }) + const resolveLoginErrorMessage = (error: any) => { const rawMessage = error?.message ?? @@ -227,9 +290,6 @@ export default function LoginPage({ if (authErrorCode === 'EMAIL_PASSWORD_DISABLED') { return loginCopy.errors.emailPasswordDisabled } - if (authErrorCode === 'FAILED_TO_CREATE_SESSION') { - return loginCopy.errors.failedToCreateSession - } if (authErrorCode === 'TOO_MANY_ATTEMPTS' || searchable.includes('too many attempts')) { return loginCopy.errors.tooManyAttempts } @@ -278,6 +338,9 @@ export default function LoginPage({ } try { + await prepareAuthStart() + + let requiresReauthCleanup = false const result = await client.signIn.email( { email, @@ -287,24 +350,18 @@ export default function LoginPage({ { onError: (ctx) => { console.error('Login error:', ctx.error) + if (isSessionRecoveryError(ctx.error)) { + requiresReauthCleanup = true + return + } + const errorMessage: string[] = [] const resolvedMessage = resolveLoginErrorMessage(ctx.error) - const status = - (ctx.error as any)?.status ?? - (ctx.error as any)?.statusCode ?? - (ctx.error as any)?.response?.status - if (resolvedMessage === null) { return } - // If the backend rejected the request due to an invalid/expired auth state, hard reset auth. - if (status === 401) { - handleAuthError('login-unauthorized').catch(() => {}) - errorMessage.push(loginCopy.errors.sessionExpired) - } - if (resolvedMessage) { errorMessage.push(resolvedMessage) } @@ -320,6 +377,15 @@ export default function LoginPage({ ) if (!result || result.error) { + if (requiresReauthCleanup || isSessionRecoveryError(result?.error)) { + shouldRunReauthCleanupRef.current = true + void runReauthCleanup() + setPasswordErrors([loginCopy.errors.unableToSignInNow]) + setShowValidationError(true) + setIsLoading(false) + return + } + const message = resolveLoginErrorMessage(result?.error) ?? loginCopy.errors.unableToSignInNow @@ -336,6 +402,13 @@ export default function LoginPage({ router.push('/verify') return } + if (isSessionRecoveryError(err)) { + shouldRunReauthCleanupRef.current = true + void runReauthCleanup() + setPasswordErrors([loginCopy.errors.unableToSignInNow]) + setShowValidationError(true) + return + } console.error('Uncaught login error:', err) } finally { @@ -551,8 +624,15 @@ export default function LoginPage({ githubAvailable={githubAvailable} isProduction={isProduction} callbackURL={callbackUrl} + beforeSignIn={prepareAuthStart} > - {ssoEnabled && } + {ssoEnabled && ( + + )} )} diff --git a/apps/tradinggoose/app/(auth)/signup/signup-form.tsx b/apps/tradinggoose/app/(auth)/signup/signup-form.tsx index 9e6f5325d..5e5461118 100644 --- a/apps/tradinggoose/app/(auth)/signup/signup-form.tsx +++ b/apps/tradinggoose/app/(auth)/signup/signup-form.tsx @@ -7,7 +7,7 @@ import { useLocale } from 'next-intl' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' -import { client, useSession } from '@/lib/auth-client' +import { client } from '@/lib/auth-client' import { quickValidateEmail } from '@/lib/email/validation' import { getEnv, isTruthy } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' @@ -90,7 +90,6 @@ function SignupFormContent({ const signupCopy = copy.auth.signup const defaultCallbackPath = '/workspace' const searchParams = useSearchParams() - const { refetch: refetchSession } = useSession() const [isLoading, setIsLoading] = useState(false) const [, setMounted] = useState(false) const [showPassword, setShowPassword] = useState(false) @@ -348,21 +347,6 @@ function SignupFormContent({ return } - try { - await refetchSession() - const localeResponse = await fetch('/api/users/me/settings', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ preferredLocale: locale }), - }) - if (!localeResponse.ok) { - throw new Error('Failed to persist preferred locale after signup') - } - logger.info('Session refreshed after successful signup') - } catch (sessionError) { - logger.error('Failed to refresh session or persist locale after signup:', sessionError) - } - if (typeof window !== 'undefined') { sessionStorage.setItem('verificationEmail', emailValue) if (isInviteFlow && redirectUrl) { diff --git a/apps/tradinggoose/app/(auth)/sso/sso-form.tsx b/apps/tradinggoose/app/(auth)/sso/sso-form.tsx index 399d41d26..590098f1e 100644 --- a/apps/tradinggoose/app/(auth)/sso/sso-form.tsx +++ b/apps/tradinggoose/app/(auth)/sso/sso-form.tsx @@ -2,22 +2,22 @@ import { useEffect, useState } from 'react' import { useSearchParams } from 'next/navigation' +import { useMessages } from 'next-intl' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { resolveSsoAuthErrorMessage } from '@/lib/auth/auth-error-copy' import { useAuthRedirectUrls } from '@/lib/auth/redirect-urls' import { client } from '@/lib/auth-client' import { quickValidateEmail } from '@/lib/email/validation' -import { normalizeAuthErrorCode } from '@/lib/auth/auth-error-copy' import { createLogger } from '@/lib/logs/console/logger' import { getAuthRegistrationHref, type RegistrationMode } from '@/lib/registration/shared' import { cn } from '@/lib/utils' -import { Link } from '@/i18n/navigation' -import { useMessages } from 'next-intl' -import { normalizeCallbackUrl } from '@/i18n/utils' import { AuthPageHeader } from '@/app/(auth)/components/auth-page-header' import { AuthWaitlistNote } from '@/app/(auth)/components/auth-waitlist-note' import { inter } from '@/app/fonts/inter' +import { Link } from '@/i18n/navigation' +import { normalizeCallbackUrl } from '@/i18n/utils' const logger = createLogger('SSOForm') @@ -81,24 +81,8 @@ export default function SSOForm({ registrationMode }: { registrationMode: Regist if (emailParam) { setEmail(emailParam) } - - const error = searchParams.get('error') - if (error) { - const errorMessages: Record = { - account_not_found: ssoCopy.errors.accountNotFound, - sso_failed: ssoCopy.errors.ssoFailed, - invalid_provider: ssoCopy.errors.providerNotConfigured, - } - setEmailErrors([errorMessages[error] || ssoCopy.errors.ssoFailed]) - setShowEmailValidationError(true) - } } - }, [ - searchParams, - ssoCopy.errors.accountNotFound, - ssoCopy.errors.providerNotConfigured, - ssoCopy.errors.ssoFailed, - ]) + }, [searchParams]) const handleEmailChange = (e: React.ChangeEvent) => { const newEmail = e.target.value @@ -136,32 +120,14 @@ export default function SSOForm({ registrationMode }: { registrationMode: Regist await client.signIn.sso({ email: emailValue, callbackURL: authRedirectUrls.providerCallbackPath(callbackUrl), - errorCallbackURL: authRedirectUrls.providerErrorPath( - `/sso?error=sso_failed&callbackUrl=${callbackUrlParam}` - ), + errorCallbackURL: authRedirectUrls.providerErrorPath(callbackUrl), }) } catch (err) { logger.error('SSO sign-in failed', { error: err, email: emailValue }) - const authErrorCode = err instanceof Error ? normalizeAuthErrorCode(err.message) : null - - let errorMessage = ssoCopy.errors.failed - if (err instanceof Error) { - if (authErrorCode === 'NO_PROVIDER_FOUND' || authErrorCode === 'INVALID_PROVIDER') { - errorMessage = ssoCopy.errors.providerNotConfigured - } else if (authErrorCode === 'INVALID_EMAIL_DOMAIN') { - errorMessage = ssoCopy.errors.invalidEmailDomain - } else if (authErrorCode === 'NETWORK_ERROR') { - errorMessage = ssoCopy.errors.network - } else if (authErrorCode === 'RATE_LIMIT' || authErrorCode === 'TOO_MANY_REQUESTS') { - errorMessage = ssoCopy.errors.rateLimit - } else if (authErrorCode === 'SSO_DISABLED') { - errorMessage = ssoCopy.errors.ssoDisabled - } else { - errorMessage = ssoCopy.errors.failed - } - } + const errorMessage = + err instanceof Error ? resolveSsoAuthErrorMessage(copy, err.message) : null - setEmailErrors([errorMessage]) + setEmailErrors([errorMessage ?? ssoCopy.errors.failed]) setShowEmailValidationError(true) setIsLoading(false) } @@ -252,14 +218,14 @@ export default function SSOForm({ registrationMode }: { registrationMode: Regist )}
{commonCopy.termsLeadSigningIn}{' '} {commonCopy.termsOfService} {' '} @@ -268,7 +234,7 @@ export default function SSOForm({ registrationMode }: { registrationMode: Regist href='/privacy' target='_blank' rel='noopener noreferrer' - className='hover:text-primary underline underline-offset-4' + className='underline underline-offset-4 hover:text-primary' > {commonCopy.privacyPolicy} diff --git a/apps/tradinggoose/app/(auth)/verify/use-verification.test.tsx b/apps/tradinggoose/app/(auth)/verify/use-verification.test.tsx index a14cc5538..ff5e5037d 100644 --- a/apps/tradinggoose/app/(auth)/verify/use-verification.test.tsx +++ b/apps/tradinggoose/app/(auth)/verify/use-verification.test.tsx @@ -13,6 +13,7 @@ const mockPush = vi.hoisted(() => vi.fn()) const mockEmailOtpSignIn = vi.hoisted(() => vi.fn()) const mockSendVerificationOtp = vi.hoisted(() => vi.fn()) const mockRefetchSession = vi.hoisted(() => vi.fn()) +const mockFetch = vi.hoisted(() => vi.fn()) const testState = vi.hoisted(() => ({ searchParams: new URLSearchParams(), })) @@ -122,8 +123,11 @@ describe('useVerification', () => { mockEmailOtpSignIn.mockReset() mockSendVerificationOtp.mockReset() mockRefetchSession.mockReset() + mockFetch.mockReset() + mockFetch.mockResolvedValue(new Response('{}', { status: 200 })) testState.searchParams = new URLSearchParams() window.history.replaceState({}, '', '/') + global.fetch = mockFetch as typeof fetch }) afterEach(() => { @@ -172,6 +176,15 @@ describe('useVerification', () => { email: 'ada@example.com', otp: '123456', }) + expect(mockFetch).toHaveBeenCalledWith( + '/api/users/me/settings', + expect.objectContaining({ + method: 'PATCH', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ preferredLocale: 'zh' }), + }) + ) expect(mockPush).toHaveBeenCalledWith('/workspace') }) @@ -198,4 +211,34 @@ describe('useVerification', () => { expect(mockPush).toHaveBeenCalledWith('/workspace/ws-1/dashboard') }) + + it('persists locale before redirecting when verification is disabled', async () => { + mockRefetchSession.mockResolvedValue(undefined) + + await act(async () => { + root.render( + + {}} + /> + + ) + }) + + await act(async () => {}) + + expect(mockRefetchSession).toHaveBeenCalled() + expect(mockFetch).toHaveBeenCalledWith( + '/api/users/me/settings', + expect.objectContaining({ + method: 'PATCH', + credentials: 'include', + body: JSON.stringify({ preferredLocale: 'es' }), + }) + ) + expect(mockPush).toHaveBeenCalledWith('/workspace') + }) }) diff --git a/apps/tradinggoose/app/(auth)/verify/use-verification.ts b/apps/tradinggoose/app/(auth)/verify/use-verification.ts index bfed0ca5a..769bdb22a 100644 --- a/apps/tradinggoose/app/(auth)/verify/use-verification.ts +++ b/apps/tradinggoose/app/(auth)/verify/use-verification.ts @@ -2,11 +2,12 @@ import { useEffect, useState } from 'react' import { useSearchParams } from 'next/navigation' +import { useLocale } from 'next-intl' import { useRouter } from '@/i18n/navigation' import { normalizeAuthErrorCode } from '@/lib/auth/auth-error-copy' import { client, useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' -import { normalizeCallbackUrl } from '@/i18n/utils' +import { normalizeCallbackUrl, type LocaleCode } from '@/i18n/utils' import type { Messages } from 'next-intl' const logger = createLogger('useVerification') @@ -95,6 +96,7 @@ export function useVerification({ copy, }: UseVerificationParams): UseVerificationReturn { const router = useRouter() + const locale = useLocale() as LocaleCode const searchParams = useSearchParams() const { refetch: refetchSession } = useSession() const [otp, setOtp] = useState('') @@ -162,6 +164,23 @@ export function useVerification({ const isOtpComplete = otp.length === 6 + async function persistPreferredLocale() { + try { + const response = await fetch('/api/users/me/settings', { + method: 'PATCH', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ preferredLocale: locale }), + }) + + if (!response.ok) { + throw new Error('Failed to persist preferred locale after verification') + } + } catch (error) { + logger.warn('Failed to persist preferred locale after verification', { error, locale }) + } + } + async function verifyCode() { if (!isOtpComplete || !email) return @@ -185,6 +204,8 @@ export function useVerification({ logger.warn('Failed to refetch session after verification', e) } + await persistPreferredLocale() + if (typeof window !== 'undefined') { sessionStorage.removeItem('verificationEmail') @@ -278,6 +299,8 @@ export function useVerification({ logger.warn('Failed to refetch session during verification skip:', error) } + await persistPreferredLocale() + if (isInviteFlow && redirectUrl) { router.push(redirectUrl) } else { diff --git a/apps/tradinggoose/app/(landing)/components/feature/components/landing-canvas/landing-canvas.tsx b/apps/tradinggoose/app/(landing)/components/feature/components/landing-canvas/landing-canvas.tsx index 7330b5c4b..b6fa23abb 100644 --- a/apps/tradinggoose/app/(landing)/components/feature/components/landing-canvas/landing-canvas.tsx +++ b/apps/tradinggoose/app/(landing)/components/feature/components/landing-canvas/landing-canvas.tsx @@ -123,18 +123,6 @@ export function LandingCanvas({
- {/* Use template button overlay */} - {/* */} -
({ @@ -73,9 +74,11 @@ vi.mock('@/components/ui/dropdown-menu', () => ({ vi.mock('@/components/listing-selector/listing/row', () => ({ getListingDisplaySymbol: (listing: { base?: string | null; name?: string | null }) => listing?.base || listing?.name || 'Listing', - ListingDisplayRow: ({ listing }: { listing?: { base?: string | null; name?: string | null } }) => ( - {listing?.base || listing?.name || 'Listing'} - ), + ListingDisplayRow: ({ + listing, + }: { + listing?: { base?: string | null; name?: string | null } + }) => {listing?.base || listing?.name || 'Listing'}, })) vi.mock('@/components/listing-selector/selector/resolve-request', () => ({ requestListingResolution: vi.fn().mockResolvedValue(null), @@ -107,11 +110,8 @@ vi.mock('@/widgets/widgets/components/widget-header-control', () => ({ widgetHeaderMenuTextClassName: '', })) -import { - buildTradingAgentWorkflowDemos, - type WorkflowPreviewDemo, -} from './workflow-preview-demos' import { WorkflowPreview } from './workflow-preview' +import { buildTradingAgentWorkflowDemos, type WorkflowPreviewDemo } from './workflow-preview-demos' describe('WorkflowPreview', () => { let container: HTMLDivElement @@ -145,12 +145,11 @@ describe('WorkflowPreview', () => { await act(async () => { root.render( - - - + + + + + ) }) @@ -247,15 +246,16 @@ describe('WorkflowPreview', () => { await act(async () => { root.render( - - - + + + + + ) }) - expect(container.textContent).toContain(copy.workspace.widgets.workflowEditor.summary.objectItem) + expect(container.textContent).toContain( + copy.workspace.widgets.workflowEditor.summary.objectItem + ) }) }) diff --git a/apps/tradinggoose/app/(landing)/components/nav/nav.test.tsx b/apps/tradinggoose/app/(landing)/components/nav/nav.test.tsx index 81efaf923..26f570df4 100644 --- a/apps/tradinggoose/app/(landing)/components/nav/nav.test.tsx +++ b/apps/tradinggoose/app/(landing)/components/nav/nav.test.tsx @@ -17,7 +17,14 @@ const mockReplace = vi.fn() const mockRefresh = vi.fn() const mockReplaceLocaleDocument = vi.fn() const mockUpdateSetting = vi.fn() -let mockSessionUserId: string | null = null +const mockSetTheme = vi.fn() +let mockSessionUser: { + id: string + email: string + name?: string | null + image?: string | null + updatedAt?: Date +} | null = null let mockPathname = '/' let mockSearchParams = '' const flush = () => new Promise((resolve) => setTimeout(resolve, 0)) @@ -83,16 +90,75 @@ vi.mock('@/lib/branding/branding', () => ({ vi.mock('@/lib/auth-client', () => ({ useSession: () => ({ - data: mockSessionUserId ? { user: { id: mockSessionUserId } } : null, + data: mockSessionUser ? { user: mockSessionUser } : null, isPending: false, error: null, refetch: vi.fn(), }), + signOut: vi.fn(), })) vi.mock('@/stores/settings/general/store', () => ({ - useGeneralStore: (selector: (state: { updateSetting: typeof mockUpdateSetting }) => unknown) => - selector({ updateSetting: mockUpdateSetting }), + useGeneralStore: ( + selector: (state: { + theme: 'system' + setTheme: typeof mockSetTheme + updateSetting: typeof mockUpdateSetting + isLoading: boolean + isThemeLoading: boolean + }) => unknown + ) => + selector({ + theme: 'system', + setTheme: mockSetTheme, + updateSetting: mockUpdateSetting, + isLoading: false, + isThemeLoading: false, + }), +})) + +vi.mock('@/hooks/queries/organization', () => ({ + useOrganizations: () => ({ + data: { + activeOrganization: null, + billingData: { data: { billingEnabled: false } }, + }, + }), + useOrganizationBilling: () => ({ data: null }), +})) + +vi.mock('@/hooks/queries/subscription', () => ({ + useSubscriptionData: () => ({ + data: { billingEnabled: false }, + isLoading: false, + }), +})) + +vi.mock('@/lib/billing/billing-portal', () => ({ + openBillingPortal: vi.fn(), +})) + +vi.mock('@/lib/environment', () => ({ + isHosted: false, +})) + +vi.mock('@/stores', () => ({ + clearUserData: vi.fn(), +})) + +vi.mock('@/global-navbar/settings-modal/components/help/help-modal', () => ({ + HelpModal: () => null, +})) + +vi.mock('@/global-navbar/settings-modal/settings-dialog', () => ({ + SettingsDialog: ({ + open, + section, + }: { + open: boolean + section: string + onOpenChange: (open: boolean) => void + }) => (open ?
{section}
: null), })) describe('landing nav registration mode', () => { @@ -108,7 +174,8 @@ describe('landing nav registration mode', () => { vi.clearAllMocks() vi.mocked(getRegistrationModeForRender).mockReset() mockUpdateSetting.mockResolvedValue(undefined) - mockSessionUserId = null + mockSetTheme.mockResolvedValue(undefined) + mockSessionUser = null mockPathname = '/' mockSearchParams = '' container = document.createElement('div') @@ -129,7 +196,11 @@ describe('landing nav registration mode', () => { vi.mocked(getRegistrationModeForRender).mockResolvedValue('waitlist') await act(async () => { - root.render(await PublicNav()) + root.render( + + {await PublicNav()} + + ) }) expect(getRegistrationModeForRender).toHaveBeenCalledTimes(1) @@ -141,7 +212,11 @@ describe('landing nav registration mode', () => { it('reuses an already resolved registration mode when provided', async () => { await act(async () => { - root.render(await PublicNav({ registrationMode: 'disabled' })) + root.render( + + {await PublicNav({ registrationMode: 'disabled' })} + + ) }) expect(getRegistrationModeForRender).not.toHaveBeenCalled() @@ -165,6 +240,112 @@ describe('landing nav registration mode', () => { expect(container.textContent).toContain(getPublicCopy('en').registration.open.primary) }) + it('routes ordinary login navigation without reauth cleanup', async () => { + await act(async () => { + root.render( + +
-
- + + {isAuthenticated ? ( + + ) : null} + ) } diff --git a/apps/tradinggoose/app/(landing)/components/structured-data.tsx b/apps/tradinggoose/app/(landing)/components/structured-data.tsx index e7c39f175..250f10986 100644 --- a/apps/tradinggoose/app/(landing)/components/structured-data.tsx +++ b/apps/tradinggoose/app/(landing)/components/structured-data.tsx @@ -2,13 +2,15 @@ import { getLocale } from 'next-intl/server' import { getPublicBillingCatalog } from '@/lib/billing/catalog' import { buildHostedPricingNarrative } from '@/lib/billing/public-catalog' import { getPublicCopy } from '@/i18n/public-copy' -import { type LocaleCode, localizeSiteUrl, SITE_BASE_URL } from '@/i18n/utils' +import { type LocaleCode, localizeSiteUrl } from '@/i18n/utils' +import { getBaseUrl } from '@/lib/urls/utils' const STRUCTURED_DATA_MODIFIED_AT = '2026-04-04T00:00:00+00:00' -const siteEntityUrl = (id: string) => `${SITE_BASE_URL}/#${id}` -const siteAssetUrl = (pathname: string) => `${SITE_BASE_URL}${pathname}` -function buildStructuredOffers(catalog: Awaited>) { +function buildStructuredOffers( + catalog: Awaited>, + siteEntityUrl: (id: string) => string +) { if (!catalog.billingEnabled) { return [] } @@ -86,6 +88,9 @@ export default async function StructuredData() { const billingCatalog = await getPublicBillingCatalog() const locale = (await getLocale()) as LocaleCode const copy = getPublicCopy(locale) + const siteBaseUrl = getBaseUrl() + const siteEntityUrl = (id: string) => `${siteBaseUrl}/#${id}` + const siteAssetUrl = (pathname: string) => `${siteBaseUrl}${pathname}` const siteHomeUrl = localizeSiteUrl(locale, '/') const pricingNarrative = billingCatalog.billingEnabled ? buildHostedPricingNarrative(billingCatalog) @@ -211,7 +216,7 @@ export default async function StructuredData() { applicationSubCategory: 'Trading Platform', operatingSystem: 'Web, Windows, macOS, Linux', softwareVersion: '2026.04.04', - offers: buildStructuredOffers(billingCatalog), + offers: buildStructuredOffers(billingCatalog, siteEntityUrl), featureList: [ 'Visual workflow canvas for trading strategies', 'Custom indicator editor (PineTS)', diff --git a/apps/tradinggoose/app/[locale]/(auth)/auth-entry-pages.test.tsx b/apps/tradinggoose/app/[locale]/(auth)/auth-entry-pages.test.tsx new file mode 100644 index 000000000..4169a0682 --- /dev/null +++ b/apps/tradinggoose/app/[locale]/(auth)/auth-entry-pages.test.tsx @@ -0,0 +1,177 @@ +import type React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getAuthErrorCallbackPath } from '@/lib/auth/auth-error-copy' + +const mockGetLocale = vi.fn() +const mockGetSession = vi.fn() +const mockRedirect = vi.fn((url: string) => { + throw new Error(`redirect:${url}`) +}) +const mockGetBrandConfig = vi.fn() +const mockGetOAuthProviderStatus = vi.fn() +const mockGetRegistrationModeForRender = vi.fn() + +vi.mock('next-intl/server', () => ({ + getLocale: () => mockGetLocale(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: (...args: unknown[]) => mockGetSession(...args), +})) + +vi.mock('@/lib/branding/branding', () => ({ + getBrandConfig: () => mockGetBrandConfig(), +})) + +vi.mock('@/i18n/navigation', () => ({ + Link: ({ + children, + href, + ...props + }: React.AnchorHTMLAttributes & { + children?: React.ReactNode + href: string + }) => ( + + {children} + + ), + redirect: ({ href, locale }: { href: string; locale?: string }) => { + const localizedPath = locale && href.startsWith('/') ? `/${locale}${href}` : href + return mockRedirect(localizedPath) + }, +})) + +vi.mock('@/app/(auth)/components/oauth-provider-checker', () => ({ + getOAuthProviderStatus: () => mockGetOAuthProviderStatus(), +})) + +vi.mock('@/lib/registration/service', () => ({ + getRegistrationModeForRender: () => mockGetRegistrationModeForRender(), +})) + +vi.mock('@/app/(auth)/components/auth-page-header', () => ({ + AuthPageHeader: () => null, +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ children }: { children?: React.ReactNode }) => , +})) + +vi.mock('@/app/(auth)/login/login-form', () => ({ + default: ({ registrationMode }: { registrationMode: string }) => ( +
+ ), +})) + +vi.mock('@/app/(auth)/signup/signup-form', () => ({ + default: ({ registrationMode }: { registrationMode: string }) => ( +
+ ), +})) + +describe('localized auth entry pages', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + mockGetLocale.mockResolvedValue('es') + mockGetSession.mockResolvedValue(null) + mockGetOAuthProviderStatus.mockResolvedValue({ + githubAvailable: false, + googleAvailable: false, + isProduction: false, + }) + mockGetRegistrationModeForRender.mockResolvedValue('open') + mockGetBrandConfig.mockReturnValue({ supportEmail: 'support@tradinggoose.ai' }) + }) + + it('redirects login to the localized workspace when a session is present', async () => { + mockGetSession.mockResolvedValue({ + user: { + id: 'user-1', + }, + }) + + const LoginPage = (await import('./login/page')).default + + await expect(LoginPage()).rejects.toThrow('redirect:/es/workspace') + expect(mockGetSession).toHaveBeenCalledWith() + expect(mockGetOAuthProviderStatus).not.toHaveBeenCalled() + expect(mockGetRegistrationModeForRender).not.toHaveBeenCalled() + }) + + it('renders login reauth routes without redirecting an existing session first', async () => { + mockGetSession.mockResolvedValue({ + user: { + id: 'user-1', + }, + }) + + const LoginPage = (await import('./login/page')).default + + const result = await LoginPage({ searchParams: Promise.resolve({ reauth: '1' }) }) + const markup = renderToStaticMarkup(result) + + expect(markup).toContain('data-testid="login-form"') + expect(mockGetSession).not.toHaveBeenCalled() + expect(mockRedirect).not.toHaveBeenCalled() + }) + + it('renders login when the session check is empty', async () => { + const LoginPage = (await import('./login/page')).default + + const result = await LoginPage() + const markup = renderToStaticMarkup(result) + + expect(markup).toContain('data-testid="login-form"') + expect(markup).toContain('data-registration-mode="open"') + expect(mockRedirect).not.toHaveBeenCalled() + }) + + it('redirects signup to the localized workspace when a session is present', async () => { + mockGetSession.mockResolvedValue({ + user: { + id: 'user-1', + }, + }) + + const SignupPage = (await import('./signup/page')).default + + await expect(SignupPage({ searchParams: Promise.resolve({}) })).rejects.toThrow( + 'redirect:/es/workspace' + ) + expect(mockGetSession).toHaveBeenCalledWith() + expect(mockGetOAuthProviderStatus).not.toHaveBeenCalled() + expect(mockGetRegistrationModeForRender).not.toHaveBeenCalled() + }) + + it('renders signup when the session check is empty', async () => { + const SignupPage = (await import('./signup/page')).default + + const result = await SignupPage({ searchParams: Promise.resolve({}) }) + const markup = renderToStaticMarkup(result) + + expect(markup).toContain('data-testid="signup-form"') + expect(markup).toContain('data-registration-mode="open"') + expect(mockRedirect).not.toHaveBeenCalled() + }) + + it('keeps provider error callback state in the per-flow error URL', async () => { + const callbackPath = getAuthErrorCallbackPath('/invite/invitation-1?token=workspace-token') + const ErrorPage = (await import('./error/[[...callback]]/page')).default + + const result = await ErrorPage({ + params: Promise.resolve({ + callback: callbackPath?.split('/').filter(Boolean).slice(1), + }), + searchParams: Promise.resolve({ error: 'UNABLE_TO_CREATE_SESSION' }), + }) + const markup = renderToStaticMarkup(result) + + expect(markup).toContain( + '/login?reauth=1&callbackUrl=%2Finvite%2Finvitation-1%3Ftoken%3Dworkspace-token' + ) + expect(markup).not.toContain('document.cookie') + }) +}) diff --git a/apps/tradinggoose/app/[locale]/(auth)/error/page.tsx b/apps/tradinggoose/app/[locale]/(auth)/error/[[...callback]]/page.tsx similarity index 81% rename from apps/tradinggoose/app/[locale]/(auth)/error/page.tsx rename to apps/tradinggoose/app/[locale]/(auth)/error/[[...callback]]/page.tsx index 816bf46d4..ff5476244 100644 --- a/apps/tradinggoose/app/[locale]/(auth)/error/page.tsx +++ b/apps/tradinggoose/app/[locale]/(auth)/error/[[...callback]]/page.tsx @@ -1,33 +1,34 @@ import { getLocale } from 'next-intl/server' import { Button } from '@/components/ui/button' -import { getAuthErrorContent } from '@/lib/auth/auth-error-copy' +import { getAuthErrorContent, normalizeAuthErrorCallbackSegments } from '@/lib/auth/auth-error-copy' import { getBrandConfig } from '@/lib/branding/branding' import { AuthPageHeader } from '@/app/(auth)/components/auth-page-header' import { inter } from '@/app/fonts/inter' import { Link } from '@/i18n/navigation' import { getPublicCopy } from '@/i18n/public-copy' -import { type LocaleCode } from '@/i18n/utils' - -export const dynamic = 'force-dynamic' +import type { LocaleCode } from '@/i18n/utils' function getSingleSearchParam(value: string | string[] | undefined) { return Array.isArray(value) ? value[0] : value } export default async function AuthErrorPage({ + params, searchParams, }: { + params?: Promise<{ callback?: string[] }> searchParams?: Promise<{ error?: string | string[] error_description?: string | string[] }> }) { - const resolvedSearchParams = (await searchParams) ?? {} - const error = getSingleSearchParam(resolvedSearchParams.error) - const errorDescription = getSingleSearchParam(resolvedSearchParams.error_description) + const [resolvedParams, resolvedSearchParams] = await Promise.all([params, searchParams]) + const error = getSingleSearchParam(resolvedSearchParams?.error) + const errorDescription = getSingleSearchParam(resolvedSearchParams?.error_description) + const callbackUrl = normalizeAuthErrorCallbackSegments(resolvedParams?.callback) const locale = (await getLocale()) as LocaleCode const copy = getPublicCopy(locale) - const { code, content } = getAuthErrorContent(copy, error, errorDescription) + const { code, content } = getAuthErrorContent(copy, error, errorDescription, callbackUrl) const brand = getBrandConfig() const supportEmail = brand.supportEmail const errorCopy = copy.auth.error @@ -47,9 +48,7 @@ export default async function AuthErrorPage({ > {errorCopy.codeLabel}

- - {code} - + {code}
) : null} diff --git a/apps/tradinggoose/app/[locale]/(auth)/login/page.tsx b/apps/tradinggoose/app/[locale]/(auth)/login/page.tsx index 216d56b48..448bc8ee7 100644 --- a/apps/tradinggoose/app/[locale]/(auth)/login/page.tsx +++ b/apps/tradinggoose/app/[locale]/(auth)/login/page.tsx @@ -1,11 +1,29 @@ +import { getLocale } from 'next-intl/server' import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker' import LoginForm from '@/app/(auth)/login/login-form' +import { getSession } from '@/lib/auth' import { getRegistrationModeForRender } from '@/lib/registration/service' +import { redirect } from '@/i18n/navigation' // Force dynamic rendering to avoid prerender errors with search params export const dynamic = 'force-dynamic' -export default async function LoginPage() { +export default async function LoginPage({ + searchParams, +}: { + searchParams?: Promise<{ reauth?: string }> +} = {}) { + const query = await searchParams + const isReauth = query?.reauth === '1' + const [locale, session] = await Promise.all([ + getLocale(), + isReauth ? Promise.resolve(null) : getSession(), + ]) + + if (session?.user?.id) { + redirect({ href: '/workspace', locale }) + } + const [{ githubAvailable, googleAvailable, isProduction }, registrationMode] = await Promise.all([ getOAuthProviderStatus(), getRegistrationModeForRender(), 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]/(auth)/signup/page.tsx b/apps/tradinggoose/app/[locale]/(auth)/signup/page.tsx index 6e11f3db2..b0f8bdf78 100644 --- a/apps/tradinggoose/app/[locale]/(auth)/signup/page.tsx +++ b/apps/tradinggoose/app/[locale]/(auth)/signup/page.tsx @@ -1,11 +1,11 @@ import { getLocale } from 'next-intl/server' -import { Link } from '@/i18n/navigation' +import { Link, redirect } from '@/i18n/navigation' import { getPublicCopy } from '@/i18n/public-copy' -import { type LocaleCode } from '@/i18n/utils' import { AuthPageHeader } from '@/app/(auth)/components/auth-page-header' import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker' import SignupForm from '@/app/(auth)/signup/signup-form' import { Button } from '@/components/ui/button' +import { getSession } from '@/lib/auth' import { getRegistrationModeForRender } from '@/lib/registration/service' export const dynamic = 'force-dynamic' @@ -15,12 +15,18 @@ export default async function SignupPage({ }: { searchParams?: Promise<{ invite_flow?: string }> }) { - const [providers, locale] = await Promise.all([ - Promise.all([getOAuthProviderStatus(), getRegistrationModeForRender()]), + const [locale, session] = await Promise.all([ getLocale(), + getSession(), ]) + + if (session?.user?.id) { + redirect({ href: '/workspace', locale }) + } + + const providers = await Promise.all([getOAuthProviderStatus(), getRegistrationModeForRender()]) const [{ githubAvailable, googleAvailable, isProduction }, registrationMode] = providers - const copy = getPublicCopy(locale as LocaleCode) + const copy = getPublicCopy(locale) const commonCopy = copy.auth.common const disabledCopy = copy.auth.disabled const resolvedSearchParams = (await searchParams) ?? {} diff --git a/apps/tradinggoose/app/[locale]/(landing)/blog/[slug]/page.tsx b/apps/tradinggoose/app/[locale]/(landing)/blog/[slug]/page.tsx index 895f0eeda..0d12eae0f 100644 --- a/apps/tradinggoose/app/[locale]/(landing)/blog/[slug]/page.tsx +++ b/apps/tradinggoose/app/[locale]/(landing)/blog/[slug]/page.tsx @@ -12,9 +12,9 @@ import { buildLocalizedAlternates, getOpenGraphLocale, localizeSiteUrl, - SITE_BASE_URL, type LocaleCode, } from '@/i18n/utils' +import { getBaseUrl } from '@/lib/urls/utils' import { getPostBySlug } from '@/app/(landing)/blog/lib/posts' import { formatBlogDate } from '@/app/(landing)/blog/lib/heading-slugs' import BreadcrumbNav from '@/app/(landing)/blog/components/breadcrumb-nav' @@ -74,6 +74,7 @@ export default async function PostPage({ params }: PostPageProps) { if (!post) notFound() const locale = (await getLocale()) as LocaleCode const copy = getPublicCopy(locale) + const siteBaseUrl = getBaseUrl() const { title, date, image, authors, tags, toc, content, readingTime } = post const postPath = `/blog/${slug}` @@ -102,7 +103,7 @@ export default async function PostPage({ params }: PostPageProps) { ], })), }), - publisher: { '@id': `${SITE_BASE_URL}/#organization` }, + publisher: { '@id': `${siteBaseUrl}/#organization` }, mainEntityOfPage: { '@type': 'WebPage', '@id': localizeSiteUrl(locale, postPath) }, ...(tags?.length && { keywords: tags.join(', '), articleSection: tags[0] }), inLanguage: locale, diff --git a/apps/tradinggoose/app/[locale]/(landing)/layout.tsx b/apps/tradinggoose/app/[locale]/(landing)/layout.tsx index 6af3cd044..f58e9bf0b 100644 --- a/apps/tradinggoose/app/[locale]/(landing)/layout.tsx +++ b/apps/tradinggoose/app/[locale]/(landing)/layout.tsx @@ -1,18 +1,20 @@ import type { Metadata } from 'next' import Background from '@/app/(landing)/components/background/background' -import { SITE_BASE_URL } from '@/i18n/utils' +import { getBaseUrl } from '@/lib/urls/utils' -export const metadata: Metadata = { - metadataBase: new URL(SITE_BASE_URL), - manifest: '/manifest.webmanifest', - icons: { - icon: '/favicon.ico', - apple: '/apple-icon.png', - }, - other: { - 'msapplication-TileColor': '#000000', - 'theme-color': '#000000', - }, +export function generateMetadata(): Metadata { + return { + metadataBase: new URL(getBaseUrl()), + manifest: '/manifest.webmanifest', + icons: { + icon: '/favicon.ico', + apple: '/apple-icon.png', + }, + other: { + 'msapplication-TileColor': '#000000', + 'theme-color': '#000000', + }, + } } export default function LandingLayout({ children }: { children: React.ReactNode }) { diff --git a/apps/tradinggoose/app/[locale]/[...notFound]/page.tsx b/apps/tradinggoose/app/[locale]/[...notFound]/page.tsx new file mode 100644 index 000000000..68abc79dd --- /dev/null +++ b/apps/tradinggoose/app/[locale]/[...notFound]/page.tsx @@ -0,0 +1,5 @@ +import { notFound } from 'next/navigation' + +export default function LocalizedNotFoundRoute() { + notFound() +} diff --git a/apps/tradinggoose/app/[locale]/admin/layout.test.tsx b/apps/tradinggoose/app/[locale]/admin/layout.test.tsx index bfab86a80..6e86f0004 100644 --- a/apps/tradinggoose/app/[locale]/admin/layout.test.tsx +++ b/apps/tradinggoose/app/[locale]/admin/layout.test.tsx @@ -1,10 +1,12 @@ import type React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CANONICAL_CALLBACK_PATH_HEADER } from '@/i18n/utils' let capturedGlobalNavbarProps: | { isSystemAdmin?: boolean + workspaceUser?: { id: string; email: string | null } | null navigationMode?: 'workspace' | 'admin' } | undefined @@ -12,12 +14,39 @@ let capturedGlobalNavbarProps: const mockNotFound = vi.fn(() => { throw new Error('notFound') }) +const mockRedirect = vi.fn((url: string) => { + throw new Error(`redirect:${url}`) +}) const mockGetSystemAdminAccess = vi.fn() +const mockHeaders = vi.fn() vi.mock('next/navigation', () => ({ notFound: () => mockNotFound(), })) +vi.mock('next/headers', () => ({ + headers: () => mockHeaders(), +})) + +vi.mock('@/i18n/navigation', () => ({ + redirect: ({ + href, + locale, + }: { + href: string | { pathname: string; query?: Record } + locale?: string + }) => { + const canonicalPath = + typeof href === 'string' + ? href + : `${href.pathname}${href.query ? `?${new URLSearchParams(href.query).toString()}` : ''}` + const localizedPath = + locale && canonicalPath.startsWith('/') ? `/${locale}${canonicalPath}` : canonicalPath + + return mockRedirect(localizedPath) + }, +})) + vi.mock('@/lib/admin/access', () => ({ getSystemAdminAccess: (...args: unknown[]) => mockGetSystemAdminAccess(...args), })) @@ -26,13 +55,15 @@ vi.mock('@/global-navbar', () => ({ GlobalNavbar: ({ children, isSystemAdmin, + workspaceUser, navigationMode, }: { children: React.ReactNode isSystemAdmin?: boolean + workspaceUser?: { id: string; email: string | null } | null navigationMode?: 'workspace' | 'admin' }) => { - capturedGlobalNavbarProps = { isSystemAdmin, navigationMode } + capturedGlobalNavbarProps = { isSystemAdmin, workspaceUser, navigationMode } return
{children}
}, })) @@ -42,11 +73,19 @@ describe('Admin layout', () => { vi.clearAllMocks() vi.resetModules() capturedGlobalNavbarProps = undefined + mockHeaders.mockResolvedValue(new Headers()) + + mockRedirect.mockImplementation((url: string) => { + throw new Error(`redirect:${url}`) + }) }) it('renders admin content inside the admin navbar', async () => { mockGetSystemAdminAccess.mockResolvedValue({ + isAuthenticated: true, isSystemAdmin: false, + userId: 'admin-user-1', + user: { email: 'admin@example.com' }, canBootstrapSystemAdmin: true, }) @@ -59,12 +98,71 @@ describe('Admin layout', () => { expect(renderToStaticMarkup(result)).toContain('admin content') expect(capturedGlobalNavbarProps).toEqual({ isSystemAdmin: false, + workspaceUser: { + id: 'admin-user-1', + email: 'admin@example.com', + }, navigationMode: 'admin', }) + expect(mockGetSystemAdminAccess).toHaveBeenCalledWith(expect.any(Headers)) + }) + + it('redirects signed-out admin entry to login with the current callback target', async () => { + mockHeaders.mockResolvedValue( + new Headers([[CANONICAL_CALLBACK_PATH_HEADER, '/admin/billing?from=nav']]) + ) + mockGetSystemAdminAccess.mockResolvedValue({ + isAuthenticated: false, + isSystemAdmin: false, + canBootstrapSystemAdmin: false, + }) + + const AdminLayout = (await import('./layout')).default + + await expect( + AdminLayout({ + children:
admin content
, + params: Promise.resolve({ locale: 'es' }), + }) + ).rejects.toThrow('redirect:/es/login?callbackUrl=%2Fadmin%2Fbilling%3Ffrom%3Dnav') + + expect(mockRedirect).toHaveBeenCalledWith( + '/es/login?callbackUrl=%2Fadmin%2Fbilling%3Ffrom%3Dnav' + ) + expect(mockNotFound).not.toHaveBeenCalled() + }) + + it('routes invalid admin session cookies through reauth cleanup', async () => { + mockHeaders.mockResolvedValue( + new Headers([ + [CANONICAL_CALLBACK_PATH_HEADER, '/admin/billing?from=nav'], + ['cookie', 'better-auth.session_token=stale'], + ]) + ) + mockGetSystemAdminAccess.mockResolvedValue({ + isAuthenticated: false, + isSystemAdmin: false, + canBootstrapSystemAdmin: false, + }) + + const AdminLayout = (await import('./layout')).default + + await expect( + AdminLayout({ + children:
admin content
, + params: Promise.resolve({ locale: 'es' }), + }) + ).rejects.toThrow('redirect:/es/login?reauth=1&callbackUrl=%2Fadmin%2Fbilling%3Ffrom%3Dnav') + + expect(mockRedirect).toHaveBeenCalledWith( + '/es/login?reauth=1&callbackUrl=%2Fadmin%2Fbilling%3Ffrom%3Dnav' + ) + expect(mockNotFound).not.toHaveBeenCalled() }) it('calls notFound when the user cannot access admin routes', async () => { mockGetSystemAdminAccess.mockResolvedValue({ + isAuthenticated: true, isSystemAdmin: false, canBootstrapSystemAdmin: false, }) diff --git a/apps/tradinggoose/app/[locale]/admin/layout.tsx b/apps/tradinggoose/app/[locale]/admin/layout.tsx index d546c5f25..c9a8781e5 100644 --- a/apps/tradinggoose/app/[locale]/admin/layout.tsx +++ b/apps/tradinggoose/app/[locale]/admin/layout.tsx @@ -1,10 +1,13 @@ import type React from 'react' +import { getSessionCookie } from 'better-auth/cookies' +import { headers } from 'next/headers' import { notFound } from 'next/navigation' import { NextIntlClientProvider } from 'next-intl' import { getSystemAdminAccess } from '@/lib/admin/access' import { GlobalNavbar } from '@/global-navbar' +import { redirect } from '@/i18n/navigation' import { getClientMessages } from '@/i18n/public-copy' -import type { LocaleCode } from '@/i18n/utils' +import { type LocaleCode, requireCanonicalCallbackPath } from '@/i18n/utils' export default async function AdminLayout({ children, @@ -13,8 +16,22 @@ export default async function AdminLayout({ children: React.ReactNode params: Promise<{ locale: string }> }) { - const [{ locale: routeLocale }, access] = await Promise.all([params, getSystemAdminAccess()]) + const [{ locale: routeLocale }, requestHeaders] = await Promise.all([params, headers()]) const locale = routeLocale as LocaleCode + const access = await getSystemAdminAccess(requestHeaders) + + if (!access.isAuthenticated) { + return redirect({ + href: { + pathname: '/login', + query: { + ...(getSessionCookie(requestHeaders) ? { reauth: '1' } : {}), + callbackUrl: requireCanonicalCallbackPath(requestHeaders, 'admin'), + }, + }, + locale, + }) + } if (!access.isSystemAdmin && !access.canBootstrapSystemAdmin) { notFound() @@ -22,7 +39,18 @@ export default async function AdminLayout({ return ( - + {children} diff --git a/apps/tradinggoose/app/[locale]/changelog/page.tsx b/apps/tradinggoose/app/[locale]/changelog/page.tsx index a30f3f79b..5c8de468f 100644 --- a/apps/tradinggoose/app/[locale]/changelog/page.tsx +++ b/apps/tradinggoose/app/[locale]/changelog/page.tsx @@ -7,8 +7,8 @@ import { getOpenGraphLocale, type LocaleCode, localizeSiteUrl, - SITE_BASE_URL, } from '@/i18n/utils' +import { getBaseUrl } from '@/lib/urls/utils' export async function generateMetadata(): Promise { const locale = (await getLocale()) as LocaleCode @@ -36,6 +36,7 @@ export async function generateMetadata(): Promise { export default async function ChangelogPage() { const locale = (await getLocale()) as LocaleCode const copy = getPublicCopy(locale) + const siteBaseUrl = getBaseUrl() const changelogStructuredData = { '@context': 'https://schema.org', '@graph': [ @@ -46,10 +47,10 @@ export default async function ChangelogPage() { url: localizeSiteUrl(locale, '/changelog'), mainEntityOfPage: localizeSiteUrl(locale, '/changelog'), inLanguage: locale, - author: { '@id': `${SITE_BASE_URL}/#organization` }, - publisher: { '@id': `${SITE_BASE_URL}/#organization` }, - about: { '@id': `${SITE_BASE_URL}/#software` }, - isPartOf: { '@id': `${SITE_BASE_URL}/#website` }, + author: { '@id': `${siteBaseUrl}/#organization` }, + publisher: { '@id': `${siteBaseUrl}/#organization` }, + about: { '@id': `${siteBaseUrl}/#software` }, + isPartOf: { '@id': `${siteBaseUrl}/#website` }, }, { '@type': 'BreadcrumbList', diff --git a/apps/tradinggoose/app/[locale]/layout.tsx b/apps/tradinggoose/app/[locale]/layout.tsx index 76bd0c153..b35dfe463 100644 --- a/apps/tradinggoose/app/[locale]/layout.tsx +++ b/apps/tradinggoose/app/[locale]/layout.tsx @@ -10,9 +10,9 @@ import { type AppLocale, routing } from '@/i18n/routing' import 'monaco-editor/min/vs/editor/editor.main.css' import '@/app/globals.css' +import { AppBootstrap } from '@/app/app-bootstrap' import { TooltipProvider } from '@/components/ui/tooltip' import { SessionProvider } from '@/lib/session/session-context' -import { ProviderModelsBootstrap } from '@/app/provider-models-bootstrap' import { QueryProvider } from '@/app/query-provider' import { ThemeProvider } from '@/app/theme-provider' import { ZoomPrevention } from '@/app/zoom-prevention' @@ -26,6 +26,8 @@ export const viewport: Viewport = { ], } +export const dynamic = 'force-dynamic' + export async function generateMetadata({ params, }: { @@ -37,10 +39,6 @@ export async function generateMetadata({ ) } -export function generateStaticParams() { - return routing.locales.map((locale) => ({ locale })) -} - export default async function RootLayout({ children, params, @@ -74,7 +72,7 @@ export default async function RootLayout({ locale={locale} messages={getClientMessages(locale)} > - + {children} diff --git a/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/layout.test.tsx b/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/layout.test.tsx index 4ce4d0070..7fcfec243 100644 --- a/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/layout.test.tsx +++ b/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/layout.test.tsx @@ -23,9 +23,7 @@ vi.mock('@/i18n/navigation', () => ({ ? href : `${href.pathname}${href.query ? `?${new URLSearchParams(href.query).toString()}` : ''}` const localizedPath = - locale && locale !== 'en' && canonicalPath.startsWith('/') - ? `/${locale}${canonicalPath}` - : canonicalPath + locale && canonicalPath.startsWith('/') ? `/${locale}${canonicalPath}` : canonicalPath return mockRedirect(localizedPath) }, @@ -44,8 +42,18 @@ vi.mock('@/lib/permissions/utils', () => ({ })) vi.mock('@/app/workspace/[workspaceId]/providers/providers', () => ({ - default: ({ children, workspaceId }: { children: React.ReactNode; workspaceId?: string }) => ( -
{children}
+ default: ({ + children, + workspaceId, + userId, + }: { + children: React.ReactNode + workspaceId: string + userId: string + }) => ( +
+ {children} +
), })) @@ -68,6 +76,33 @@ describe('Workspace layout access guard', () => { const WorkspaceLayout = (await import('./layout')).default + await expect( + WorkspaceLayout({ + children:
workspace
, + params: Promise.resolve({ locale: 'es', workspaceId: 'ws-1' }), + }) + ).rejects.toThrow( + 'redirect:/es/login?callbackUrl=%2Fworkspace%2Fws-1%2Ffiles%3FlayoutId%3Dlayout-1' + ) + + expect(mockRedirect).toHaveBeenCalledWith( + '/es/login?callbackUrl=%2Fworkspace%2Fws-1%2Ffiles%3FlayoutId%3Dlayout-1' + ) + expect(mockGetSession).toHaveBeenCalledWith(expect.any(Headers)) + expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() + }) + + it('routes invalid session cookies through reauth cleanup', async () => { + mockHeaders.mockResolvedValue( + new Headers([ + [CANONICAL_CALLBACK_PATH_HEADER, '/workspace/ws-1/files?layoutId=layout-1'], + ['cookie', 'better-auth.session_token=stale'], + ]) + ) + mockGetSession.mockResolvedValue(null) + + const WorkspaceLayout = (await import('./layout')).default + await expect( WorkspaceLayout({ children:
workspace
, @@ -80,11 +115,10 @@ describe('Workspace layout access guard', () => { expect(mockRedirect).toHaveBeenCalledWith( '/es/login?reauth=1&callbackUrl=%2Fworkspace%2Fws-1%2Ffiles%3FlayoutId%3Dlayout-1' ) - expect(mockGetSession).toHaveBeenCalledWith(expect.any(Headers), { disableCookieCache: true }) expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() }) - it('redirects to /workspace when the user cannot access the workspace', async () => { + it('redirects to the localized workspace root when the user cannot access the workspace', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-1', @@ -104,10 +138,10 @@ describe('Workspace layout access guard', () => { children:
workspace
, params: Promise.resolve({ locale: 'en', workspaceId: 'ws-1' }), }) - ).rejects.toThrow('redirect:/workspace') + ).rejects.toThrow('redirect:/en/workspace') expect(mockCheckWorkspaceAccess).toHaveBeenCalledWith('ws-1', 'user-1') - expect(mockRedirect).toHaveBeenCalledWith('/workspace') + expect(mockRedirect).toHaveBeenCalledWith('/en/workspace') }) it('renders the workspace route when access is valid', async () => { @@ -131,8 +165,11 @@ describe('Workspace layout access guard', () => { params: Promise.resolve({ locale: 'en', workspaceId: 'ws-1' }), }) - expect(renderToStaticMarkup(result)).toContain('data-workspace-id="ws-1"') - expect(renderToStaticMarkup(result)).toContain('workspace') + const markup = renderToStaticMarkup(result) + + expect(markup).toContain('workspace') + expect(markup).toContain('data-workspace-id="ws-1"') + expect(markup).toContain('data-user-id="user-1"') expect(mockRedirect).not.toHaveBeenCalled() }) }) diff --git a/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/layout.tsx b/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/layout.tsx index e1e5e1475..f7832c191 100644 --- a/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/layout.tsx +++ b/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/layout.tsx @@ -1,9 +1,10 @@ +import { getSessionCookie } from 'better-auth/cookies' import { headers } from 'next/headers' import { getSession } from '@/lib/auth' import { checkWorkspaceAccess } from '@/lib/permissions/utils' import Providers from '@/app/workspace/[workspaceId]/providers/providers' import { redirect } from '@/i18n/navigation' -import { CANONICAL_CALLBACK_PATH_HEADER, type LocaleCode } from '@/i18n/utils' +import { type LocaleCode, requireCanonicalCallbackPath } from '@/i18n/utils' export default async function WorkspaceLayout({ children, @@ -15,21 +16,16 @@ export default async function WorkspaceLayout({ const { locale: routeLocale, workspaceId } = await params const locale = routeLocale as LocaleCode const requestHeaders = await headers() - const session = await getSession(requestHeaders, { disableCookieCache: true }) + const session = await getSession(requestHeaders) const userId = session?.user?.id if (!userId) { - const callbackUrl = requestHeaders.get(CANONICAL_CALLBACK_PATH_HEADER) - if (!callbackUrl) { - throw new Error('Missing canonical callback path for workspace reauth redirect') - } - return redirect({ href: { pathname: '/login', query: { - reauth: '1', - callbackUrl, + ...(getSessionCookie(requestHeaders) ? { reauth: '1' } : {}), + callbackUrl: requireCanonicalCallbackPath(requestHeaders, 'workspace'), }, }, locale, @@ -43,7 +39,7 @@ export default async function WorkspaceLayout({ } return ( - +
{children}
diff --git a/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/templates/[id]/page.tsx b/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/templates/[id]/page.tsx deleted file mode 100644 index 68b165a99..000000000 --- a/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/templates/[id]/page.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { db } from '@tradinggoose/db' -import { templateStars, templates } from '@tradinggoose/db/schema' -import { and, eq } from 'drizzle-orm' -import { getLocale } from 'next-intl/server' -import { notFound } from 'next/navigation' -import { getSession } from '@/lib/auth' -import { createLogger } from '@/lib/logs/console/logger' -import { getPublicCopy } from '@/i18n/public-copy' -import type { LocaleCode } from '@/i18n/utils' -import TemplateDetails from '@/app/workspace/[workspaceId]/templates/[id]/template' -import type { Template } from '@/app/workspace/[workspaceId]/templates/templates' - -const logger = createLogger('TemplatePage') - -interface TemplatePageProps { - params: Promise<{ - workspaceId: string - id: string - }> -} - -export default async function TemplatePage({ params }: TemplatePageProps) { - const locale = (await getLocale()) as LocaleCode - const copy = getPublicCopy(locale).workspace.templates - const { workspaceId, id } = await params - - try { - // Validate the template ID format (basic UUID validation) - if (!id || typeof id !== 'string' || id.length !== 36) { - notFound() - } - - const session = await getSession() - - if (!session?.user?.id) { - return
{copy.loginRequired}
- } - - // Fetch template data first without star status to avoid query issues - const templateData = await db - .select({ - id: templates.id, - workflowId: templates.workflowId, - userId: templates.userId, - name: templates.name, - description: templates.description, - author: templates.author, - views: templates.views, - stars: templates.stars, - color: templates.color, - icon: templates.icon, - category: templates.category, - state: templates.state, - createdAt: templates.createdAt, - updatedAt: templates.updatedAt, - }) - .from(templates) - .where(eq(templates.id, id)) - .limit(1) - - if (templateData.length === 0) { - notFound() - } - - const template = templateData[0] - - // Validate that required fields are present - if (!template.id || !template.name || !template.author) { - logger.error('Template missing required fields:', { - id: template.id, - name: template.name, - author: template.author, - }) - notFound() - } - - // Check if user has starred this template - let isStarred = false - try { - const starData = await db - .select({ id: templateStars.id }) - .from(templateStars) - .where( - and(eq(templateStars.templateId, template.id), eq(templateStars.userId, session.user.id)) - ) - .limit(1) - isStarred = starData.length > 0 - } catch { - // Continue with isStarred = false - } - - // Ensure proper serialization of the template data with null checks - const serializedTemplate: Template = { - id: template.id, - workflowId: template.workflowId, - userId: template.userId, - name: template.name, - description: template.description, - author: template.author, - views: template.views, - stars: template.stars, - color: template.color || '#3972F6', // Default color if missing - icon: template.icon || 'FileText', // Default icon if missing - category: template.category as any, - state: template.state as any, - createdAt: template.createdAt ? template.createdAt.toISOString() : new Date().toISOString(), - updatedAt: template.updatedAt ? template.updatedAt.toISOString() : new Date().toISOString(), - isStarred, - } - - logger.info('Template from DB:', template) - logger.info('Serialized template:', serializedTemplate) - logger.info('Template state from DB:', template.state) - - return ( - - ) - } catch (error) { - console.error('Error loading template:', error) - return ( -
-
-

{copy.errorPage.title}

-

{copy.errorPage.description}

-

- {copy.errorPage.templateIdLabel} {id} -

-
-
- ) - } -} diff --git a/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/templates/layout.tsx b/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/templates/layout.tsx deleted file mode 100644 index 0de233f32..000000000 --- a/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/templates/layout.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { GeistSans } from 'geist/font/sans' - -export default function TemplatesLayout({ children }: { children: React.ReactNode }) { - return
{children}
-} diff --git a/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/templates/page.tsx b/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/templates/page.tsx deleted file mode 100644 index f25e8f5bd..000000000 --- a/apps/tradinggoose/app/[locale]/workspace/[workspaceId]/templates/page.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { db } from '@tradinggoose/db' -import { templateStars, templates } from '@tradinggoose/db/schema' -import { and, desc, eq, sql } from 'drizzle-orm' -import { getLocale } from 'next-intl/server' -import { getSession } from '@/lib/auth' -import { getPublicCopy } from '@/i18n/public-copy' -import type { LocaleCode } from '@/i18n/utils' -import type { Template } from '@/app/workspace/[workspaceId]/templates/templates' -import Templates from '@/app/workspace/[workspaceId]/templates/templates' - -export default async function TemplatesPage() { - const locale = (await getLocale()) as LocaleCode - const copy = getPublicCopy(locale).workspace.templates - const session = await getSession() - - if (!session?.user?.id) { - return
{copy.loginRequired}
- } - - // Fetch templates server-side with all necessary data - const templatesData = await db - .select({ - id: templates.id, - workflowId: templates.workflowId, - userId: templates.userId, - name: templates.name, - description: templates.description, - author: templates.author, - views: templates.views, - stars: templates.stars, - color: templates.color, - icon: templates.icon, - category: templates.category, - state: templates.state, - createdAt: templates.createdAt, - updatedAt: templates.updatedAt, - isStarred: sql`CASE WHEN ${templateStars.id} IS NOT NULL THEN true ELSE false END`, - }) - .from(templates) - .leftJoin( - templateStars, - and(eq(templateStars.templateId, templates.id), eq(templateStars.userId, session.user.id)) - ) - .orderBy(desc(templates.views), desc(templates.createdAt)) - - return ( - - ) -} diff --git a/apps/tradinggoose/app/[locale]/workspace/layout.tsx b/apps/tradinggoose/app/[locale]/workspace/layout.tsx index 39a0817b7..589a72de0 100644 --- a/apps/tradinggoose/app/[locale]/workspace/layout.tsx +++ b/apps/tradinggoose/app/[locale]/workspace/layout.tsx @@ -19,7 +19,19 @@ export default async function WorkspaceRootLayout({ return ( - {children} + + {children} + ) diff --git a/apps/tradinggoose/app/[locale]/workspace/page.test.tsx b/apps/tradinggoose/app/[locale]/workspace/page.test.tsx new file mode 100644 index 000000000..66acecce3 --- /dev/null +++ b/apps/tradinggoose/app/[locale]/workspace/page.test.tsx @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CANONICAL_CALLBACK_PATH_HEADER } from '@/i18n/utils' + +const mockRedirect = vi.fn((url: string) => { + throw new Error(`redirect:${url}`) +}) +const mockGetSession = vi.fn() +const mockHeaders = vi.fn() +const mockGetUserWorkspaces = vi.fn() +const mockCreateDefaultWorkspaceForUser = vi.fn() +const mockReadWorkflowAccessContext = vi.fn() + +function mockLocalizedRedirect({ + href, + locale, +}: { + href: string | { pathname: string; query?: Record } + locale?: string +}) { + const canonicalPath = + typeof href === 'string' + ? href + : `${href.pathname}${href.query ? `?${new URLSearchParams(href.query).toString()}` : ''}` + const localizedPath = + locale && canonicalPath.startsWith('/') ? `/${locale}${canonicalPath}` : canonicalPath + return mockRedirect(localizedPath) +} + +vi.mock('@/i18n/navigation', () => ({ + redirect: mockLocalizedRedirect, +})) + +vi.mock('next/headers', () => ({ + headers: () => mockHeaders(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: (...args: unknown[]) => mockGetSession(...args), +})) + +vi.mock('@/lib/workspaces/service', () => ({ + createDefaultWorkspaceForUser: (...args: unknown[]) => mockCreateDefaultWorkspaceForUser(...args), + getUserWorkspaces: (...args: unknown[]) => mockGetUserWorkspaces(...args), +})) + +vi.mock('@/lib/workflows/utils', () => ({ + readWorkflowAccessContext: (...args: unknown[]) => mockReadWorkflowAccessContext(...args), +})) + +async function renderWorkspacePage( + locale = 'en', + searchParams: { callbackUrl?: string; redirect_workflow?: string } = {} +) { + const WorkspacePage = (await import('./page')).default + return WorkspacePage({ + params: Promise.resolve({ locale }), + searchParams: Promise.resolve(searchParams), + }) +} + +describe('Workspace root page access guard', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + mockHeaders.mockResolvedValue(new Headers()) + + mockRedirect.mockImplementation((url: string) => { + throw new Error(`redirect:${url}`) + }) + mockGetSession.mockResolvedValue({ + user: { + id: 'user-1', + name: 'Ada Lovelace', + }, + }) + mockGetUserWorkspaces.mockResolvedValue([{ id: 'workspace-1' }]) + mockCreateDefaultWorkspaceForUser.mockResolvedValue({ id: 'workspace-created' }) + mockReadWorkflowAccessContext.mockResolvedValue(null) + }) + + it('redirects signed-out users to login with the current callback target', async () => { + mockHeaders.mockResolvedValue( + new Headers([[CANONICAL_CALLBACK_PATH_HEADER, '/workspace?redirect_workflow=workflow-1']]) + ) + mockGetSession.mockResolvedValue(null) + + await expect(renderWorkspacePage('zh')).rejects.toThrow( + 'redirect:/zh/login?callbackUrl=%2Fworkspace%3Fredirect_workflow%3Dworkflow-1' + ) + + expect(mockRedirect).toHaveBeenCalledWith( + '/zh/login?callbackUrl=%2Fworkspace%3Fredirect_workflow%3Dworkflow-1' + ) + expect(mockGetSession).toHaveBeenCalledWith(expect.any(Headers)) + }) + + it('routes invalid session cookies through reauth cleanup', async () => { + mockHeaders.mockResolvedValue( + new Headers([ + [CANONICAL_CALLBACK_PATH_HEADER, '/workspace?redirect_workflow=workflow-1'], + ['cookie', 'better-auth.session_token=stale'], + ]) + ) + mockGetSession.mockResolvedValue(null) + + await expect(renderWorkspacePage('zh')).rejects.toThrow( + 'redirect:/zh/login?reauth=1&callbackUrl=%2Fworkspace%3Fredirect_workflow%3Dworkflow-1' + ) + + expect(mockRedirect).toHaveBeenCalledWith( + '/zh/login?reauth=1&callbackUrl=%2Fworkspace%3Fredirect_workflow%3Dworkflow-1' + ) + expect(mockGetSession).toHaveBeenCalledWith(expect.any(Headers)) + }) + + it('redirects authenticated users to the requested workflow workspace', async () => { + mockReadWorkflowAccessContext.mockResolvedValue({ + workflow: { + workspaceId: 'workspace-from-workflow', + }, + isOwner: false, + isWorkspaceOwner: false, + workspacePermission: 'read', + }) + await expect(renderWorkspacePage('en', { redirect_workflow: 'workflow-1' })).rejects.toThrow( + 'redirect:/en/workspace/workspace-from-workflow/dashboard' + ) + + expect(mockReadWorkflowAccessContext).toHaveBeenCalledWith('workflow-1', 'user-1') + expect(mockGetUserWorkspaces).not.toHaveBeenCalled() + }) + + it('redirects authenticated users to same-origin absolute callback URLs', async () => { + mockHeaders.mockResolvedValue(new Headers([['host', 'preview.local:3000']])) + + await expect( + renderWorkspacePage('en', { + callbackUrl: 'http://preview.local:3000/workspace/workspace-2/dashboard?layoutId=layout-1', + }) + ).rejects.toThrow('redirect:/en/workspace/workspace-2/dashboard?layoutId=layout-1') + + expect(mockGetUserWorkspaces).not.toHaveBeenCalled() + }) + + it('redirects authenticated users to their first workspace dashboard', async () => { + await expect(renderWorkspacePage('es')).rejects.toThrow( + 'redirect:/es/workspace/workspace-1/dashboard' + ) + + expect(mockGetUserWorkspaces).toHaveBeenCalledWith({ + userId: 'user-1', + }) + }) + + it('repairs authenticated users with no workspace from the workspace entrypoint', async () => { + mockGetUserWorkspaces.mockResolvedValue([]) + + await expect(renderWorkspacePage('en')).rejects.toThrow( + 'redirect:/en/workspace/workspace-created/dashboard' + ) + + 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 ef3ada300..132bd0ee6 100644 --- a/apps/tradinggoose/app/[locale]/workspace/page.tsx +++ b/apps/tradinggoose/app/[locale]/workspace/page.tsx @@ -1,151 +1,95 @@ -'use client' - -import { useEffect } from 'react' -import { useTranslations } from 'next-intl' -import { LoadingAgent } from '@/components/ui/loading-agent' -import { useSession } from '@/lib/auth-client' -import { createLogger } from '@/lib/logs/console/logger' -import { usePathname, useRouter } from '@/i18n/navigation' -import { normalizeCallbackUrl } from '@/i18n/utils' - -const logger = createLogger('WorkspacePage') - -export default function WorkspacePage() { - const router = useRouter() - const pathname = usePathname() - const tWorkspace = useTranslations('workspace') - const { data: session, isPending, error: sessionError } = useSession() - - useEffect(() => { - const redirectToFirstWorkspace = async () => { - if (isPending) { - return - } - - if (sessionError || !session?.user) { - logger.info('User not authenticated, redirecting to home', { - hasSessionError: Boolean(sessionError), - }) - router.replace('/') - return - } - - try { - const urlParams = new URLSearchParams(window.location.search) - const callbackUrl = normalizeCallbackUrl(urlParams.get('callbackUrl'), window.location.origin) - const redirectWorkflowId = urlParams.get('redirect_workflow') - - if (callbackUrl) { - const callbackPath = new URL(callbackUrl, window.location.origin).pathname - - if (callbackPath !== pathname) { - logger.info('Redirecting to callback URL from workspace root', { callbackUrl }) - router.replace(callbackUrl) - return - } - } - - if (redirectWorkflowId) { - try { - const workflowResponse = await fetch(`/api/workflows/${redirectWorkflowId}`) - if (workflowResponse.ok) { - const workflowData = await workflowResponse.json() - const workspaceId = workflowData.data?.workspaceId - - if (workspaceId) { - logger.info( - `Redirecting workflow ${redirectWorkflowId} to workspace ${workspaceId} dashboard` - ) - router.replace(`/workspace/${workspaceId}/dashboard`) - return - } - } - } catch (error) { - logger.error('Error fetching workflow for redirect:', error) - } - } - - const response = await fetch('/api/workspaces', { - credentials: 'include', - }) - - if (response.status === 401 || response.status === 403) { - logger.info('Unauthorized to fetch workspaces, redirecting to home', { - status: response.status, - }) - router.replace('/') - return - } - - if (!response.ok) { - let errorBody = '' - try { - errorBody = await response.text() - } catch {} - - logger.error('Failed to fetch workspaces for redirect', { - status: response.status, - body: errorBody, - }) - router.replace('/') - return - } - - const data = await response.json() - const workspaces = data.workspaces || [] - - if (workspaces.length === 0) { - logger.warn('No workspaces found for user, creating default workspace') - - try { - const createResponse = await fetch('/api/workspaces', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ name: tWorkspace('defaults.newWorkspaceName') }), - }) +import { getSessionCookie } from 'better-auth/cookies' +import { headers } from 'next/headers' +import { getSession } from '@/lib/auth' +import { readWorkflowAccessContext } from '@/lib/workflows/utils' +import { createDefaultWorkspaceForUser, getUserWorkspaces } from '@/lib/workspaces/service' +import { redirect } from '@/i18n/navigation' +import { type LocaleCode, normalizeCallbackUrl, requireCanonicalCallbackPath } from '@/i18n/utils' + +type WorkspaceSearchParams = Promise<{ + callbackUrl?: string | string[] + redirect_workflow?: string | string[] +}> + +function getSearchParam( + searchParams: Awaited, + key: keyof Awaited +) { + const value = searchParams[key] + return Array.isArray(value) ? value[0] : value +} - if (createResponse.ok) { - const createData = await createResponse.json() - const newWorkspace = createData.workspace +function normalizeRequestCallbackUrl(href: string | null | undefined, headers: Headers) { + const internalCallback = normalizeCallbackUrl(href) + if (internalCallback) { + return internalCallback + } - if (newWorkspace?.id) { - logger.info( - `Created default workspace ${newWorkspace.id}, redirecting to dashboard` - ) - router.replace(`/workspace/${newWorkspace.id}/dashboard`) - return - } - } + const host = (headers.get('x-forwarded-host') ?? headers.get('host'))?.split(',', 1)[0]?.trim() + const forwardedProtocol = headers.get('x-forwarded-proto')?.split(',', 1)[0]?.trim() + const protocols = forwardedProtocol ? [forwardedProtocol] : ['http', 'https'] - logger.error('Failed to create default workspace') - } catch (createError) { - logger.error('Error creating default workspace:', createError) - } + for (const protocol of host ? protocols : []) { + const callback = normalizeCallbackUrl(href, `${protocol}://${host}`) + if (callback) { + return callback + } + } - router.replace('/') - return - } + return null +} - const firstWorkspace = workspaces[0] - logger.info(`Redirecting to workspace ${firstWorkspace.id} dashboard`) - router.replace(`/workspace/${firstWorkspace.id}/dashboard`) - } catch (error) { - logger.error('Error fetching workspaces for redirect:', error) - router.replace('/') - } +export default async function WorkspacePage({ + params, + searchParams, +}: { + params: Promise<{ locale: string }> + searchParams: WorkspaceSearchParams +}) { + const [{ locale: routeLocale }, query, requestHeaders] = await Promise.all([ + params, + searchParams, + headers(), + ]) + const locale = routeLocale as LocaleCode + const session = await getSession(requestHeaders) + const userId = session?.user?.id + + if (!userId) { + return redirect({ + href: { + pathname: '/login', + query: { + ...(getSessionCookie(requestHeaders) ? { reauth: '1' } : {}), + callbackUrl: requireCanonicalCallbackPath(requestHeaders, 'workspace'), + }, + }, + locale, + }) + } + + const callbackUrl = normalizeRequestCallbackUrl( + getSearchParam(query, 'callbackUrl'), + requestHeaders + ) + if (callbackUrl && callbackUrl.split(/[?#]/, 1)[0] !== '/workspace') { + return redirect({ href: callbackUrl, locale }) + } + + const redirectWorkflowId = getSearchParam(query, 'redirect_workflow') + if (redirectWorkflowId) { + const access = await readWorkflowAccessContext(redirectWorkflowId, userId) + if ( + access?.workflow.workspaceId && + (access.isOwner || access.isWorkspaceOwner || access.workspacePermission) + ) { + return redirect({ href: `/workspace/${access.workflow.workspaceId}/dashboard`, locale }) } + } - void redirectToFirstWorkspace() - }, [isPending, pathname, router, session, sessionError, tWorkspace]) + const [workspace] = await getUserWorkspaces({ userId }) + const targetWorkspace = + workspace ?? (await createDefaultWorkspaceForUser(userId, session.user.name)) - return ( -
-
- - {tWorkspace('entry.loading')} -
-
- ) + 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/auth/socket-token/route.test.ts b/apps/tradinggoose/app/api/auth/socket-token/route.test.ts new file mode 100644 index 000000000..845ce1443 --- /dev/null +++ b/apps/tradinggoose/app/api/auth/socket-token/route.test.ts @@ -0,0 +1,74 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGenerateOneTimeToken, mockGetSession, mockHeaders } = vi.hoisted(() => ({ + mockGenerateOneTimeToken: vi.fn(), + mockGetSession: vi.fn(), + mockHeaders: vi.fn(), +})) + +vi.mock('next/headers', () => ({ + headers: () => mockHeaders(), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { + api: { + generateOneTimeToken: (...args: unknown[]) => mockGenerateOneTimeToken(...args), + }, + }, + getSession: (...args: unknown[]) => mockGetSession(...args), +})) + +describe('socket token route', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.resetModules() + mockHeaders.mockResolvedValue(new Headers([['cookie', 'better-auth.session_token=token']])) + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockGenerateOneTimeToken.mockResolvedValue({ token: 'socket-token' }) + }) + + it('uses the canonical app session before issuing a socket token', async () => { + const { POST } = await import('./route') + + const response = await POST() + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ token: 'socket-token' }) + expect(mockGetSession).toHaveBeenCalledWith(expect.any(Headers)) + expect(mockGenerateOneTimeToken).toHaveBeenCalledWith({ headers: expect.any(Headers) }) + }) + + it('rejects socket token requests without an app session', async () => { + mockGetSession.mockResolvedValue(null) + + const { POST } = await import('./route') + const response = await POST() + + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ error: 'Authentication required' }) + expect(mockGenerateOneTimeToken).not.toHaveBeenCalled() + }) + + it('does not issue a token when canonical session lookup rejects stale cookie data', async () => { + const staleCookieHeaders = new Headers([ + [ + 'cookie', + 'better-auth.session_token=revoked; better-auth.session_data=stale-session-payload', + ], + ]) + mockHeaders.mockResolvedValue(staleCookieHeaders) + mockGetSession.mockResolvedValue(null) + + const { POST } = await import('./route') + const response = await POST() + + expect(response.status).toBe(401) + expect(mockGetSession).toHaveBeenCalledWith(staleCookieHeaders) + expect(mockGenerateOneTimeToken).not.toHaveBeenCalled() + }) +}) diff --git a/apps/tradinggoose/app/api/auth/socket-token/route.ts b/apps/tradinggoose/app/api/auth/socket-token/route.ts index 5d8b4f146..4af204928 100644 --- a/apps/tradinggoose/app/api/auth/socket-token/route.ts +++ b/apps/tradinggoose/app/api/auth/socket-token/route.ts @@ -5,7 +5,7 @@ import { auth, getSession } from '@/lib/auth' export async function POST() { try { const hdrs = await headers() - const session = await getSession(hdrs, { disableCookieCache: true }) + const session = await getSession(hdrs) if (!session?.user?.id) { return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) diff --git a/apps/tradinggoose/app/api/auth/sso/register/route.test.ts b/apps/tradinggoose/app/api/auth/sso/register/route.test.ts index 044bf3805..9484d2042 100644 --- a/apps/tradinggoose/app/api/auth/sso/register/route.test.ts +++ b/apps/tradinggoose/app/api/auth/sso/register/route.test.ts @@ -47,7 +47,9 @@ vi.mock('@/lib/env', () => ({ env: { SSO_ENABLED: true, }, - getEnv: vi.fn(() => undefined), + getEnv: vi.fn((key: string) => + key === 'NEXT_PUBLIC_APP_URL' ? 'http://localhost:3000' : undefined + ), isTruthy: (value: string | boolean | number | undefined) => typeof value === 'string' ? value.toLowerCase() === 'true' || value === '1' : Boolean(value), })) diff --git a/apps/tradinggoose/app/api/auth/trello/authorize/route.test.ts b/apps/tradinggoose/app/api/auth/trello/authorize/route.test.ts index 7c9063985..459361d4d 100644 --- a/apps/tradinggoose/app/api/auth/trello/authorize/route.test.ts +++ b/apps/tradinggoose/app/api/auth/trello/authorize/route.test.ts @@ -72,9 +72,7 @@ describe('Trello authorize route', () => { const returnURL = new URL(authorizeURL.searchParams.get('return_url')!) expect(returnURL.pathname).toBe('/api/auth/trello/callback') - expect(returnURL.searchParams.get('callbackURL')).toBe( - 'http://localhost:3000/workspace/ws-1/integrations' - ) + expect(returnURL.searchParams.get('callbackURL')).toBe('/workspace/ws-1/integrations') expect(returnURL.searchParams.get('state')).toBe('trello-state') const setCookie = response.headers.get('set-cookie') diff --git a/apps/tradinggoose/app/api/auth/trello/authorize/route.ts b/apps/tradinggoose/app/api/auth/trello/authorize/route.ts index 93b7b7112..a3472ccc0 100644 --- a/apps/tradinggoose/app/api/auth/trello/authorize/route.ts +++ b/apps/tradinggoose/app/api/auth/trello/authorize/route.ts @@ -8,50 +8,43 @@ import { TRELLO_OAUTH_STATE_COOKIE, } from '@/lib/trello/auth' import { getBaseUrl } from '@/lib/urls/utils' +import { normalizeCallbackUrl } from '@/i18n/utils' export const dynamic = 'force-dynamic' const logger = createLogger('TrelloAuthorizeAPI') -function getSafeCallbackURL(request: NextRequest) { +function getCallbackPath(request: NextRequest) { const appUrl = new URL(getBaseUrl()) - const rawCallbackURL = request.nextUrl.searchParams.get('callbackURL') || '/' - - try { - const callbackURL = new URL(rawCallbackURL, appUrl.origin) - if (callbackURL.origin !== appUrl.origin) { - return appUrl.origin - } - - return callbackURL.toString() - } catch { - return appUrl.origin - } + return normalizeCallbackUrl(request.nextUrl.searchParams.get('callbackURL'), appUrl.origin) } -function redirectWithError(callbackURL: string, error: string) { - const redirectURL = new URL(callbackURL) +function redirectWithError(callbackPath: string, error: string) { + const redirectURL = new URL(callbackPath, getBaseUrl()) redirectURL.searchParams.set('error', error) return NextResponse.redirect(redirectURL) } export async function GET(request: NextRequest) { - const callbackURL = getSafeCallbackURL(request) + const callbackPath = getCallbackPath(request) + if (!callbackPath) { + return NextResponse.json({ error: 'invalid_callback_url' }, { status: 400 }) + } try { const session = await getSession(request.headers) if (!session?.user?.id) { - return redirectWithError(callbackURL, 'user_not_authenticated') + return redirectWithError(callbackPath, 'user_not_authenticated') } const apiKey = await getTrelloApiKey() if (!apiKey) { - return redirectWithError(callbackURL, 'trello_not_configured') + return redirectWithError(callbackPath, 'trello_not_configured') } const state = createTrelloOAuthState() const returnURL = new URL('/api/auth/trello/callback', getBaseUrl()) - returnURL.searchParams.set('callbackURL', callbackURL) + returnURL.searchParams.set('callbackURL', callbackPath) returnURL.searchParams.set('state', state) const authorizeURL = new URL('https://trello.com/1/authorize') @@ -68,6 +61,6 @@ export async function GET(request: NextRequest) { return response } catch (error) { logger.error('Failed to start Trello authorization', { error }) - return redirectWithError(callbackURL, 'trello_authorization_failed') + return redirectWithError(callbackPath, 'trello_authorization_failed') } } diff --git a/apps/tradinggoose/app/api/auth/trello/callback/route.ts b/apps/tradinggoose/app/api/auth/trello/callback/route.ts index 28dd2f0f0..0bbd638bb 100644 --- a/apps/tradinggoose/app/api/auth/trello/callback/route.ts +++ b/apps/tradinggoose/app/api/auth/trello/callback/route.ts @@ -1,5 +1,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { getBaseUrl } from '@/lib/urls/utils' +import { normalizeCallbackUrl } from '@/i18n/utils' export const dynamic = 'force-dynamic' @@ -13,20 +14,11 @@ const INLINE_SCRIPT_ESCAPES: Record = { '\u2029': '\\u2029', } -function getSafeCallbackURL(request: NextRequest) { - const fallback = new URL('/', getBaseUrl()) +function getCallbackURL(request: NextRequest) { + const appUrl = new URL(getBaseUrl()) const rawCallbackURL = request.nextUrl.searchParams.get('callbackURL') - - if (!rawCallbackURL) { - return fallback - } - - try { - const callbackURL = new URL(rawCallbackURL, fallback.origin) - return callbackURL.origin === fallback.origin ? callbackURL : fallback - } catch { - return fallback - } + const callbackPath = normalizeCallbackUrl(rawCallbackURL, appUrl.origin) + return callbackPath ? new URL(callbackPath, appUrl.origin) : null } function serializeForInlineScript(value: string) { @@ -116,7 +108,11 @@ function renderTrelloCallbackPage({ callbackURL, state }: { callbackURL: URL; st } export async function GET(request: NextRequest) { - const callbackURL = getSafeCallbackURL(request) + const callbackURL = getCallbackURL(request) + if (!callbackURL) { + return NextResponse.json({ error: 'invalid_callback_url' }, { status: 400 }) + } + const state = request.nextUrl.searchParams.get('state')?.trim() || '' return new NextResponse(renderTrelloCallbackPage({ callbackURL, state }), { 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/chat/utils.ts b/apps/tradinggoose/app/api/chat/utils.ts index 9eda57bed..9769785f5 100644 --- a/apps/tradinggoose/app/api/chat/utils.ts +++ b/apps/tradinggoose/app/api/chat/utils.ts @@ -4,7 +4,7 @@ import { and, eq, gte, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { isDev } from '@/lib/environment' import { createLogger } from '@/lib/logs/console/logger' -import { hasAdminPermission } from '@/lib/permissions/utils' +import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils' import { decryptSecret } from '@/lib/utils-server' import { CHAT_ERROR_CODES } from '@/app/chat/constants' @@ -31,7 +31,7 @@ export async function checkWorkflowAccessForChatCreation( } if (workflowRecord.workspaceId) { - const hasAdmin = await hasAdminPermission(userId, workflowRecord.workspaceId) + const hasAdmin = await hasWorkspaceAdminAccess(userId, workflowRecord.workspaceId) if (hasAdmin) { return { hasAccess: true, workflow: workflowRecord } } @@ -69,7 +69,7 @@ export async function checkChatAccess( } if (workflowWorkspaceId) { - const hasAdmin = await hasAdminPermission(userId, workflowWorkspaceId) + const hasAdmin = await hasWorkspaceAdminAccess(userId, workflowWorkspaceId) if (hasAdmin) { return { hasAccess: true, chat: chatRecord, workspaceId: workflowWorkspaceId } } 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/chat/route.ts b/apps/tradinggoose/app/api/copilot/chat/route.ts index 266c1726d..3a4cb1e61 100644 --- a/apps/tradinggoose/app/api/copilot/chat/route.ts +++ b/apps/tradinggoose/app/api/copilot/chat/route.ts @@ -17,6 +17,7 @@ import { createRequestTracker, createUnauthorizedResponse, } from '@/lib/copilot/auth' +import { mirrorLocalCopilotCompletionUsageReports } from '@/lib/copilot/completion-usage-billing' import { normalizeFunctionCallArguments } from '@/lib/copilot/function-call-args' import { mapSessionToApiResponse, @@ -264,6 +265,7 @@ async function persistChatMessages( function generateAndPersistTitle(params: { reviewSessionId: string message: string + userId: string model: string provider?: ProviderId requestId: string @@ -271,6 +273,7 @@ function generateAndPersistTitle(params: { }): void { requestCopilotTitle({ message: params.message, + userId: params.userId, model: params.model, provider: params.provider, }) @@ -654,7 +657,6 @@ const ChatMessageSchema = z.object({ 'logs', 'workflow_block', 'knowledge', - 'templates', 'docs', ]), label: z.string(), @@ -668,7 +670,6 @@ const ChatMessageSchema = z.object({ blockTypes: z.array(z.string()).optional(), knowledgeId: z.string().optional(), blockId: z.string().optional(), - templateId: z.string().optional(), executionId: z.string().optional(), draftSessionId: z.string().optional(), // For workflow_block, provide both workflowId and blockId @@ -942,6 +943,10 @@ export async function POST(req: NextRequest) { enqueueTurnState('in_progress', 'streaming') const forwardClientEvent = (event: Record) => { + if (event.type === 'billing.completion_usage') { + return + } + if (event.type === 'awaiting_tools') { latestTurnStatus = 'in_progress' enqueueTurnState('in_progress', 'waiting_for_tools') @@ -990,6 +995,12 @@ export async function POST(req: NextRequest) { } const event = JSON.parse(jsonStr) + if (event.type === 'billing.completion_usage') { + await mirrorLocalCopilotCompletionUsageReports({ + userId: authenticatedUserId, + reports: [event.report], + }) + } switch (event.type) { case 'tool_result': @@ -1023,6 +1034,7 @@ export async function POST(req: NextRequest) { generateAndPersistTitle({ reviewSessionId: actualReviewSessionId!, message, + userId: authenticatedUserId, model, provider: runtimeProvider, requestId: tracker.requestId, @@ -1127,6 +1139,12 @@ export async function POST(req: NextRequest) { try { const jsonStr = buffer.slice(6) const event = JSON.parse(jsonStr) + if (event.type === 'billing.completion_usage') { + await mirrorLocalCopilotCompletionUsageReports({ + userId: authenticatedUserId, + reports: [event.report], + }) + } if (event.type === 'tool_result') { streamCapture.captureToolResult(event as Record) } @@ -1304,6 +1322,13 @@ export async function POST(req: NextRequest) { } }) : undefined + await mirrorLocalCopilotCompletionUsageReports({ + userId: authenticatedUserId, + reports: Array.isArray(responseData.completionUsageReports) + ? responseData.completionUsageReports + : [], + }) + responseData.completionUsageReports = undefined if (currentSession && (responseData.content || contentBlocks?.length)) { await persistChatMessages({ @@ -1324,6 +1349,7 @@ export async function POST(req: NextRequest) { generateAndPersistTitle({ reviewSessionId: actualReviewSessionId, message, + userId: authenticatedUserId, model: providerConfig?.model ?? model, provider: providerConfig?.provider, requestId: tracker.requestId, 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 3ff6e0fe7..6d6271f68 100644 --- a/apps/tradinggoose/app/api/copilot/tools/mark-complete/route.ts +++ b/apps/tradinggoose/app/api/copilot/tools/mark-complete/route.ts @@ -7,6 +7,7 @@ import { createRequestTracker, createUnauthorizedResponse, } from '@/lib/copilot/auth' +import { mirrorLocalCopilotCompletionUsageReports } from '@/lib/copilot/completion-usage-billing' import { createLogger } from '@/lib/logs/console/logger' import { encodeSSE, SSE_HEADERS } from '@/lib/utils' import { getCopilotApiUrl, proxyCopilotRequest } from '@/app/api/copilot/proxy' @@ -22,7 +23,11 @@ const MarkCompleteSchema = z.object({ data: z.any().optional(), }) -function createTurnStateStream(body: ReadableStream, abortUpstream: () => void) { +function createTurnStateStream( + body: ReadableStream, + abortUpstream: () => void, + userId: string +) { let reader: ReadableStreamDefaultReader | null = null return new ReadableStream({ @@ -44,7 +49,15 @@ function createTurnStateStream(body: ReadableStream, abortUpstream: ) } - const forwardEvent = (event: Record) => { + const forwardEvent = async (event: Record) => { + if (event.type === 'billing.completion_usage') { + await mirrorLocalCopilotCompletionUsageReports({ + userId, + reports: [event.report], + }) + return + } + if (event.type === 'awaiting_tools') { enqueueTurnState('in_progress', 'waiting_for_tools') } else if (event.type === 'response.completed') { @@ -80,7 +93,7 @@ function createTurnStateStream(body: ReadableStream, abortUpstream: } const event = JSON.parse(payload) as Record - forwardEvent(event) + await forwardEvent(event) } } @@ -92,7 +105,7 @@ function createTurnStateStream(body: ReadableStream, abortUpstream: } const event = JSON.parse(payload) as Record - forwardEvent(event) + await forwardEvent(event) } } catch (error) { controller.error(error) @@ -138,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, @@ -166,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'), }) @@ -182,7 +175,7 @@ export async function POST(req: NextRequest) { toolCallId: parsed.id, toolName: parsed.name, }) - return new NextResponse(createTurnStateStream(agentRes.body, abortUpstream), { + return new NextResponse(createTurnStateStream(agentRes.body, abortUpstream, userId), { status: agentRes.status, headers: { ...SSE_HEADERS, @@ -211,6 +204,12 @@ export async function POST(req: NextRequest) { }) if (agentRes.ok) { + await mirrorLocalCopilotCompletionUsageReports({ + userId, + reports: Array.isArray(agentJson?.completionUsageReports) + ? agentJson.completionUsageReports + : [], + }) return NextResponse.json({ success: true }) } diff --git a/apps/tradinggoose/app/api/copilot/usage/route.test.ts b/apps/tradinggoose/app/api/copilot/usage/route.test.ts index 4026201b4..81b2bd188 100644 --- a/apps/tradinggoose/app/api/copilot/usage/route.test.ts +++ b/apps/tradinggoose/app/api/copilot/usage/route.test.ts @@ -7,7 +7,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' describe('Copilot Usage API - Context', () => { const mockCheckInternalApiKey = vi.fn() - const mockIsHosted = vi.fn() const mockProxyCopilotRequest = vi.fn() const mockIsBillingEnabledForRuntime = vi.fn() const mockGetPersonalEffectiveSubscription = vi.fn() @@ -18,8 +17,9 @@ describe('Copilot Usage API - Context', () => { const mockMarkMessageAsProcessed = vi.fn() const mockCalculateCost = vi.fn() const mockReserveCopilotUsage = vi.fn() - const mockAdjustCopilotUsageReservation = vi.fn() + const mockCommitCopilotUsageReservation = vi.fn() const mockReleaseCopilotUsageReservation = vi.fn() + const mockIsHosted = vi.fn() const createTier = (copilotCostMultiplier: number) => ({ id: `tier-${copilotCostMultiplier}`, @@ -57,7 +57,6 @@ describe('Copilot Usage API - Context', () => { vi.resetModules() mockProxyCopilotRequest.mockReset() mockCheckInternalApiKey.mockReset() - mockIsHosted.mockReset() mockIsBillingEnabledForRuntime.mockReset() mockGetPersonalEffectiveSubscription.mockReset() mockGetTierCopilotCostMultiplier.mockReset() @@ -67,13 +66,16 @@ describe('Copilot Usage API - Context', () => { mockMarkMessageAsProcessed.mockReset() mockCalculateCost.mockReset() mockReserveCopilotUsage.mockReset() - mockAdjustCopilotUsageReservation.mockReset() + mockCommitCopilotUsageReservation.mockReset() mockReleaseCopilotUsageReservation.mockReset() + mockIsHosted.mockReset() mockIsBillingEnabledForRuntime.mockResolvedValue(false) + mockIsHosted.mockReturnValue(true) mockGetPersonalEffectiveSubscription.mockResolvedValue(null) mockGetTierCopilotCostMultiplier.mockImplementation( - (tier: { copilotCostMultiplier?: number } | null | undefined) => tier?.copilotCostMultiplier ?? 1 + (tier: { copilotCostMultiplier?: number } | null | undefined) => + tier?.copilotCostMultiplier ?? 1 ) mockAccrueUserUsageCost.mockResolvedValue(true) mockResolveWorkflowBillingContext.mockResolvedValue({ @@ -98,18 +100,7 @@ describe('Copilot Usage API - Context', () => { scopeType: 'user', scopeId: 'user-1', }) - mockAdjustCopilotUsageReservation.mockResolvedValue({ - allowed: true, - status: 200, - reservationId: 'reservation-1', - reservedUsd: 3, - currentUsage: 8, - limit: 10, - remaining: 0, - activeReservedUsd: 3, - scopeType: 'user', - scopeId: 'user-1', - }) + mockCommitCopilotUsageReservation.mockImplementation(async ({ operation }) => operation()) mockReleaseCopilotUsageReservation.mockResolvedValue({ released: true, reservationId: 'reservation-1', @@ -119,7 +110,6 @@ describe('Copilot Usage API - Context', () => { }) mockCheckInternalApiKey.mockReturnValue({ success: false }) - mockIsHosted.mockReturnValue(true) vi.doMock('@tradinggoose/db', () => ({ db: {}, @@ -144,10 +134,6 @@ describe('Copilot Usage API - Context', () => { checkInternalApiKey: (...args: any[]) => mockCheckInternalApiKey(...args), })) - vi.doMock('@/lib/environment', () => ({ - isHosted: mockIsHosted(), - })) - vi.doMock('@/app/api/copilot/proxy', () => ({ proxyCopilotRequest: (...args: any[]) => mockProxyCopilotRequest(...args), getCopilotApiUrl: vi.fn(() => 'https://copilot.example.test/api/get-context-usage'), @@ -198,6 +184,10 @@ describe('Copilot Usage API - Context', () => { calculateCost: (...args: any[]) => mockCalculateCost(...args), })) + vi.doMock('@/lib/environment', () => ({ + isHosted: mockIsHosted(), + })) + vi.doMock('@/lib/billing/usage-accrual', () => ({ accrueUserUsageCost: (...args: any[]) => mockAccrueUserUsageCost(...args), })) @@ -208,8 +198,7 @@ describe('Copilot Usage API - Context', () => { vi.doMock('@/lib/copilot/usage-reservations', () => ({ reserveCopilotUsage: (...args: any[]) => mockReserveCopilotUsage(...args), - adjustCopilotUsageReservation: (...args: any[]) => - mockAdjustCopilotUsageReservation(...args), + commitCopilotUsageReservation: (...args: any[]) => mockCommitCopilotUsageReservation(...args), releaseCopilotUsageReservation: (...args: any[]) => mockReleaseCopilotUsageReservation(...args), })) @@ -237,6 +226,7 @@ describe('Copilot Usage API - Context', () => { kind: 'context', conversationId: 'conversation-1', model: 'gpt-5.4', + workspaceId: 'workspace-1', }), }) @@ -263,6 +253,7 @@ describe('Copilot Usage API - Context', () => { apiKey: 'test-copilot-key', }, userId: 'user-1', + workspaceId: 'workspace-1', }, }) expect(mockGetPersonalEffectiveSubscription).not.toHaveBeenCalled() @@ -270,283 +261,85 @@ describe('Copilot Usage API - Context', () => { expect(mockAccrueUserUsageCost).not.toHaveBeenCalled() }) - it('does not bill context usage for hosted browser-session requests even when bill is requested', async () => { - mockIsBillingEnabledForRuntime.mockResolvedValue(true) - mockIsHosted.mockReturnValue(true) - mockProxyCopilotRequest.mockResolvedValue( - new Response( - JSON.stringify({ - tokensUsed: 100, - model: 'gpt-5.4', - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - } + it.each([true, false])( + 'returns display-only context usage for hosted=%s browser sessions', + async (hosted) => { + mockIsHosted.mockReturnValue(hosted) + mockIsBillingEnabledForRuntime.mockResolvedValue(true) + mockProxyCopilotRequest.mockResolvedValue( + new Response( + JSON.stringify({ + tokensUsed: 100, + percentage: 0.1, + model: 'gpt-5.4', + contextWindow: 128000, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) ) - ) - - const request = new NextRequest('http://localhost:3000/api/copilot/usage', { - method: 'POST', - body: JSON.stringify({ - kind: 'context', - conversationId: 'conversation-browser-bill', - model: 'gpt-5.4', - bill: true, - assistantMessageId: 'assistant-message-browser', - }), - }) - - const { POST } = await import('@/app/api/copilot/usage/route') - const response = await POST(request) - - expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ - tokensUsed: 100, - model: 'gpt-5.4', - }) - expect(mockAccrueUserUsageCost).not.toHaveBeenCalled() - expect(mockMarkMessageAsProcessed).not.toHaveBeenCalled() - }) - - it('records local context billing for self-hosted browser-session requests', async () => { - mockIsBillingEnabledForRuntime.mockResolvedValue(true) - mockIsHosted.mockReturnValue(false) - mockGetPersonalEffectiveSubscription.mockResolvedValue({ - id: 'subscription-personal', - tier: createTier(2), - }) - mockProxyCopilotRequest.mockResolvedValue( - new Response( - JSON.stringify({ - tokensUsed: 100, - model: 'gpt-5.4', - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) - - const request = new NextRequest('http://localhost:3000/api/copilot/usage', { - method: 'POST', - body: JSON.stringify({ - kind: 'context', - conversationId: 'conversation-self-host-bill', - model: 'gpt-5.4', - bill: true, - assistantMessageId: 'assistant-message-self-host', - }), - }) - - const { POST } = await import('@/app/api/copilot/usage/route') - const response = await POST(request) - - expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ - tokensUsed: 100, - model: 'gpt-5.4', - billing: { - billed: true, - duplicate: false, - tokens: 100, - model: 'gpt-5.4', - cost: 3, - }, - }) - expect(mockAccrueUserUsageCost).toHaveBeenCalledWith({ - userId: 'user-1', - workflowId: undefined, - cost: 3, - extraUpdates: expect.any(Object), - reason: 'copilot_context_usage', - }) - expect(mockMarkMessageAsProcessed).toHaveBeenCalledWith( - 'copilot-billing:assistant-message-self-host', - 60 * 60 * 24 * 30 - ) - }) - - it('returns exact personal billing metadata for committed context usage', async () => { - mockIsBillingEnabledForRuntime.mockResolvedValue(true) - mockCheckInternalApiKey.mockReturnValue({ success: true }) - mockGetPersonalEffectiveSubscription.mockResolvedValue({ - id: 'subscription-personal', - tier: createTier(2), - }) - mockProxyCopilotRequest.mockResolvedValue( - new Response( - JSON.stringify({ - tokensUsed: 100, + const request = new NextRequest('http://localhost:3000/api/copilot/usage', { + method: 'POST', + body: JSON.stringify({ + kind: 'context', + conversationId: `conversation-${hosted ? 'hosted' : 'self-hosted'}`, model: 'gpt-5.4', }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) - - const request = new NextRequest('http://localhost:3000/api/copilot/usage', { - method: 'POST', - body: JSON.stringify({ - action: 'commit', - kind: 'context', - conversationId: 'conversation-2', - model: 'gpt-5.4', - userId: 'user-1', - assistantMessageId: 'assistant-message-1', - reservationId: 'reservation-1', - }), - }) + }) - const { POST } = await import('@/app/api/copilot/usage/route') - const response = await POST(request) + const { POST } = await import('@/app/api/copilot/usage/route') + const response = await POST(request) - expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ - tokensUsed: 100, - model: 'gpt-5.4', - billing: { - billed: true, - duplicate: false, - tokens: 100, + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + tokensUsed: 100, + percentage: 0.1, model: 'gpt-5.4', - cost: 3, - }, - }) - expect(mockGetPersonalEffectiveSubscription).toHaveBeenCalledWith('user-1') - expect(mockResolveWorkflowBillingContext).not.toHaveBeenCalled() - expect(mockAccrueUserUsageCost).toHaveBeenCalledWith({ - userId: 'user-1', - workflowId: undefined, - cost: 3, - extraUpdates: expect.any(Object), - reason: 'copilot_context_usage', - }) - expect(mockMarkMessageAsProcessed).toHaveBeenCalledWith( - 'copilot-billing:assistant-message-1', - 60 * 60 * 24 * 30 - ) - expect(mockReleaseCopilotUsageReservation).toHaveBeenCalledWith({ - reservationId: 'reservation-1', - }) - }) - - it('commits workflow context usage with the workflow subscription tier', async () => { - mockIsBillingEnabledForRuntime.mockResolvedValue(true) + contextWindow: 128000, + }) + expect(mockAccrueUserUsageCost).not.toHaveBeenCalled() + expect(mockMarkMessageAsProcessed).not.toHaveBeenCalled() + } + ) + + it('rejects context usage inspection without a browser session even with internal auth', async () => { mockCheckInternalApiKey.mockReturnValue({ success: true }) - mockProxyCopilotRequest.mockResolvedValue( - new Response( - JSON.stringify({ - tokensUsed: 100, - model: 'gpt-5.4', - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) - - const request = new NextRequest('http://localhost:3000/api/copilot/usage', { - method: 'POST', - body: JSON.stringify({ - action: 'commit', - kind: 'context', - conversationId: 'conversation-3', - model: 'gpt-5.4', - userId: 'user-1', - workflowId: 'workflow-1', - assistantMessageId: 'assistant-message-2', - reservationId: 'reservation-1', - }), - }) - - const { POST } = await import('@/app/api/copilot/usage/route') - const response = await POST(request) - - expect(response.status).toBe(200) - await expect(response.json()).resolves.toMatchObject({ - billing: { - billed: true, - cost: 4.5, - }, - }) - expect(mockResolveWorkflowBillingContext).toHaveBeenCalledWith({ - workflowId: 'workflow-1', - actorUserId: 'user-1', - }) - expect(mockGetPersonalEffectiveSubscription).not.toHaveBeenCalled() - expect(mockAccrueUserUsageCost).toHaveBeenCalledWith({ - userId: 'user-1', - workflowId: 'workflow-1', - cost: 4.5, - extraUpdates: expect.any(Object), - reason: 'copilot_context_usage', - }) - expect(mockReleaseCopilotUsageReservation).toHaveBeenCalledWith({ - reservationId: 'reservation-1', - }) - }) - - it('returns 500 for committed context billing when Studio cannot resolve a tier', async () => { - mockIsBillingEnabledForRuntime.mockResolvedValue(true) - mockCheckInternalApiKey.mockReturnValue({ success: true }) - mockGetPersonalEffectiveSubscription.mockResolvedValue(null) - mockProxyCopilotRequest.mockResolvedValue( - new Response( - JSON.stringify({ - tokensUsed: 100, - model: 'gpt-5.4', - }), - { - status: 200, - headers: { 'Content-Type': 'application/json' }, - } - ) - ) + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue(null), + })) const request = new NextRequest('http://localhost:3000/api/copilot/usage', { method: 'POST', body: JSON.stringify({ - action: 'commit', kind: 'context', - conversationId: 'conversation-4', + conversationId: 'conversation-1', model: 'gpt-5.4', userId: 'user-1', - assistantMessageId: 'assistant-message-3', - reservationId: 'reservation-1', }), }) const { POST } = await import('@/app/api/copilot/usage/route') const response = await POST(request) - expect(response.status).toBe(500) - expect(mockAccrueUserUsageCost).not.toHaveBeenCalled() - expect(mockMarkMessageAsProcessed).not.toHaveBeenCalled() - expect(mockReleaseCopilotUsageReservation).toHaveBeenCalledWith({ - reservationId: 'reservation-1', - }) + expect(response.status).toBe(401) + expect(mockProxyCopilotRequest).not.toHaveBeenCalled() }) - it('releases the reservation when committed context usage throws before billing completes', async () => { + it('rejects context usage commit requests because context usage is inspection-only', async () => { mockCheckInternalApiKey.mockReturnValue({ success: true }) - mockIsBillingEnabledForRuntime.mockResolvedValue(true) - mockProxyCopilotRequest.mockRejectedValue(new Error('copilot unavailable')) const request = new NextRequest('http://localhost:3000/api/copilot/usage', { method: 'POST', body: JSON.stringify({ action: 'commit', kind: 'context', - conversationId: 'conversation-5', + conversationId: 'conversation-2', model: 'gpt-5.4', userId: 'user-1', - assistantMessageId: 'assistant-message-4', + assistantMessageId: 'assistant-message-1', reservationId: 'reservation-1', }), }) @@ -554,12 +347,10 @@ describe('Copilot Usage API - Context', () => { const { POST } = await import('@/app/api/copilot/usage/route') const response = await POST(request) - expect(response.status).toBe(500) + expect(response.status).toBe(400) + expect(mockProxyCopilotRequest).not.toHaveBeenCalled() expect(mockAccrueUserUsageCost).not.toHaveBeenCalled() - expect(mockMarkMessageAsProcessed).not.toHaveBeenCalled() - expect(mockReleaseCopilotUsageReservation).toHaveBeenCalledWith({ - reservationId: 'reservation-1', - }) + expect(mockReleaseCopilotUsageReservation).not.toHaveBeenCalled() }) it('reserves shared usage budget through the internal reserve action', async () => { @@ -693,89 +484,6 @@ describe('Copilot Usage API - Context', () => { expect(mockGetPersonalEffectiveSubscription).not.toHaveBeenCalled() }) - it('adjusts shared usage budget through the internal adjust action using Studio pricing', async () => { - mockCheckInternalApiKey.mockReturnValue({ success: true }) - mockIsBillingEnabledForRuntime.mockResolvedValue(true) - mockGetPersonalEffectiveSubscription.mockResolvedValue({ - id: 'subscription-personal', - tier: createTier(2), - }) - - const request = new NextRequest('http://localhost:3000/api/copilot/usage', { - method: 'POST', - body: JSON.stringify({ - action: 'adjust', - reservationId: 'reservation-1', - userId: 'user-1', - model: 'openai/gpt-5.4', - estimatedPromptTokens: 100, - reservedCompletionTokens: 25, - reason: 'copilot_turn_model_call', - }), - }) - - const { POST } = await import('@/app/api/copilot/usage/route') - const response = await POST(request) - - expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ - allowed: true, - status: 200, - reservationId: 'reservation-1', - reservedUsd: 3, - currentUsage: 8, - limit: 10, - remaining: 0, - activeReservedUsd: 3, - scopeType: 'user', - scopeId: 'user-1', - }) - expect(mockAdjustCopilotUsageReservation).toHaveBeenCalledWith({ - reservationId: 'reservation-1', - userId: 'user-1', - workflowId: undefined, - requestedUsd: 3, - reason: 'copilot_turn_model_call', - }) - }) - - it('no-ops adjust requests when billing is disabled', async () => { - mockCheckInternalApiKey.mockReturnValue({ success: true }) - mockIsBillingEnabledForRuntime.mockResolvedValue(false) - - const request = new NextRequest('http://localhost:3000/api/copilot/usage', { - method: 'POST', - body: JSON.stringify({ - action: 'adjust', - reservationId: 'reservation-1', - userId: 'user-1', - model: 'openai/gpt-5.4', - estimatedPromptTokens: 100, - reservedCompletionTokens: 25, - reason: 'copilot_turn_model_call', - }), - }) - - const { POST } = await import('@/app/api/copilot/usage/route') - const response = await POST(request) - - expect(response.status).toBe(200) - await expect(response.json()).resolves.toEqual({ - allowed: true, - status: 200, - reservationId: 'reservation-1', - reservedUsd: 0, - currentUsage: 0, - limit: Number.MAX_SAFE_INTEGER, - remaining: Number.MAX_SAFE_INTEGER, - activeReservedUsd: 0, - scopeType: 'user', - scopeId: 'user-1', - }) - expect(mockAdjustCopilotUsageReservation).not.toHaveBeenCalled() - expect(mockGetPersonalEffectiveSubscription).not.toHaveBeenCalled() - }) - it('releases reservations through the internal release action', async () => { mockCheckInternalApiKey.mockReturnValue({ success: true }) mockIsBillingEnabledForRuntime.mockResolvedValue(true) @@ -866,8 +574,9 @@ describe('Copilot Usage API - Completion', () => { const mockHasProcessedMessage = vi.fn() const mockMarkMessageAsProcessed = vi.fn() const mockCalculateCost = vi.fn() - const mockAdjustCopilotUsageReservation = vi.fn() + const mockCommitCopilotUsageReservation = vi.fn() const mockReleaseCopilotUsageReservation = vi.fn() + const mockIsHosted = vi.fn() const createTier = (copilotCostMultiplier: number) => ({ id: `tier-${copilotCostMultiplier}`, @@ -912,17 +621,20 @@ describe('Copilot Usage API - Completion', () => { mockHasProcessedMessage.mockReset() mockMarkMessageAsProcessed.mockReset() mockCalculateCost.mockReset() - mockAdjustCopilotUsageReservation.mockReset() + mockCommitCopilotUsageReservation.mockReset() mockReleaseCopilotUsageReservation.mockReset() + mockIsHosted.mockReset() mockCheckInternalApiKey.mockReturnValue({ success: true }) mockIsBillingEnabledForRuntime.mockResolvedValue(true) + mockIsHosted.mockReturnValue(true) mockGetPersonalEffectiveSubscription.mockResolvedValue({ id: 'subscription-personal', tier: createTier(2), }) mockGetTierCopilotCostMultiplier.mockImplementation( - (tier: { copilotCostMultiplier?: number } | null | undefined) => tier?.copilotCostMultiplier ?? 1 + (tier: { copilotCostMultiplier?: number } | null | undefined) => + tier?.copilotCostMultiplier ?? 1 ) mockAccrueUserUsageCost.mockResolvedValue(true) mockResolveWorkflowBillingContext.mockResolvedValue({ @@ -935,6 +647,15 @@ describe('Copilot Usage API - Completion', () => { mockHasProcessedMessage.mockResolvedValue(false) mockMarkMessageAsProcessed.mockResolvedValue(undefined) mockCalculateCost.mockReturnValue({ total: 1.5 }) + mockCommitCopilotUsageReservation.mockImplementation(async ({ reservationId, operation }) => { + try { + return await operation() + } finally { + if (reservationId) { + await mockReleaseCopilotUsageReservation({ reservationId }) + } + } + }) mockReleaseCopilotUsageReservation.mockResolvedValue({ released: true, reservationId: 'reservation-1', @@ -954,6 +675,10 @@ describe('Copilot Usage API - Completion', () => { checkInternalApiKey: (...args: any[]) => mockCheckInternalApiKey(...args), })) + vi.doMock('@/lib/environment', () => ({ + isHosted: mockIsHosted(), + })) + vi.doMock('@/lib/billing/settings', () => ({ isBillingEnabledForRuntime: (...args: any[]) => mockIsBillingEnabledForRuntime(...args), })) @@ -977,8 +702,7 @@ describe('Copilot Usage API - Completion', () => { vi.doMock('@/lib/copilot/usage-reservations', () => ({ reserveCopilotUsage: vi.fn(), - adjustCopilotUsageReservation: (...args: any[]) => - mockAdjustCopilotUsageReservation(...args), + commitCopilotUsageReservation: (...args: any[]) => mockCommitCopilotUsageReservation(...args), releaseCopilotUsageReservation: (...args: any[]) => mockReleaseCopilotUsageReservation(...args), })) @@ -1001,15 +725,15 @@ describe('Copilot Usage API - Completion', () => { })) }) - it('records internal completion billing with canonical dotted Claude model ids', async () => { + it('records internal completion billing with canonical provider model ids', async () => { const request = new NextRequest('http://localhost:3000/api/copilot/usage', { method: 'POST', body: JSON.stringify({ action: 'commit', kind: 'completion', userId: 'user-1', - model: 'claude-sonnet-4.6', - remoteModel: 'anthropic/claude-sonnet-4.6', + model: 'anthropic/claude-sonnet-4.6', + remoteModel: 'claude-4.6-sonnet-20260217', completionId: 'completion-1', reservationId: 'reservation-1', usage: { @@ -1031,13 +755,11 @@ describe('Copilot Usage API - Completion', () => { billed: true, duplicate: false, tokens: 125, - model: 'claude-sonnet-4.6', + model: 'anthropic/claude-sonnet-4.6', cost: 3, }, }) - expect(mockHasProcessedMessage).toHaveBeenCalledWith( - 'copilot-completion-billing:completion-1' - ) + expect(mockHasProcessedMessage).toHaveBeenCalledWith('copilot-completion-billing:completion-1') expect(mockAccrueUserUsageCost).toHaveBeenCalledWith({ userId: 'user-1', workflowId: undefined, @@ -1049,12 +771,152 @@ describe('Copilot Usage API - Completion', () => { 'copilot-completion-billing:completion-1', 60 * 60 * 24 * 30 ) - expect(mockCalculateCost).toHaveBeenCalledWith('claude-sonnet-4.6', 100, 25, false) + expect(mockCalculateCost).toHaveBeenCalledWith('anthropic/claude-sonnet-4.6', 100, 25, false) + expect(mockCommitCopilotUsageReservation).toHaveBeenCalledWith({ + userId: 'user-1', + workflowId: undefined, + reservationId: 'reservation-1', + operation: expect.any(Function), + }) expect(mockReleaseCopilotUsageReservation).toHaveBeenCalledWith({ reservationId: 'reservation-1', }) }) + it('mirrors hosted Copilot completion reports into self-hosted Studio usage', async () => { + mockIsHosted.mockReturnValue(false) + mockIsBillingEnabledForRuntime.mockResolvedValue(true) + mockGetPersonalEffectiveSubscription.mockResolvedValue({ + id: 'subscription-personal', + tier: createTier(2), + }) + + const { mirrorLocalCopilotCompletionUsageReports } = await import( + '@/lib/copilot/completion-usage-billing' + ) + await mirrorLocalCopilotCompletionUsageReports({ + userId: 'user-1', + reports: [ + { + kind: 'completion', + model: 'gpt-5.4', + remoteModel: 'openai/gpt-5.4', + completionId: 'local-completion-1', + usage: { + prompt_tokens: 100, + completion_tokens: 25, + total_tokens: 125, + }, + }, + ], + }) + + expect(mockAccrueUserUsageCost).toHaveBeenCalledWith({ + userId: 'user-1', + workflowId: undefined, + cost: 3, + extraUpdates: expect.any(Object), + reason: 'copilot_completion_usage', + }) + expect(mockMarkMessageAsProcessed).toHaveBeenCalledWith( + 'copilot-completion-billing:local-completion-1', + 60 * 60 * 24 * 30 + ) + expect(mockCommitCopilotUsageReservation).toHaveBeenCalledWith({ + userId: 'user-1', + workflowId: undefined, + operation: expect.any(Function), + }) + }) + + it('ignores invalid self-hosted Copilot completion mirror reports', async () => { + mockIsHosted.mockReturnValue(false) + mockIsBillingEnabledForRuntime.mockResolvedValue(true) + + const { mirrorLocalCopilotCompletionUsageReports } = await import( + '@/lib/copilot/completion-usage-billing' + ) + await mirrorLocalCopilotCompletionUsageReports({ + userId: 'user-1', + reports: [ + { + kind: 'completion', + model: 'gpt-5.4', + usage: { + prompt_tokens: 100, + completion_tokens: 25, + total_tokens: 125, + }, + }, + ], + }) + + expect(mockAccrueUserUsageCost).not.toHaveBeenCalled() + expect(mockHasProcessedMessage).not.toHaveBeenCalled() + expect(mockCommitCopilotUsageReservation).not.toHaveBeenCalled() + }) + + it('isolates self-hosted Copilot completion mirror billing failures', async () => { + mockIsHosted.mockReturnValue(false) + mockIsBillingEnabledForRuntime.mockResolvedValue(true) + mockGetPersonalEffectiveSubscription.mockResolvedValue({ + id: 'subscription-personal', + tier: createTier(2), + }) + mockCalculateCost.mockImplementation(() => { + throw new Error('pricing unavailable') + }) + + const { mirrorLocalCopilotCompletionUsageReports } = await import( + '@/lib/copilot/completion-usage-billing' + ) + await mirrorLocalCopilotCompletionUsageReports({ + userId: 'user-1', + reports: [ + { + kind: 'completion', + model: 'gpt-5.4', + completionId: 'local-completion-2', + usage: { + prompt_tokens: 100, + completion_tokens: 25, + total_tokens: 125, + }, + }, + ], + }) + + expect(mockAccrueUserUsageCost).not.toHaveBeenCalled() + expect(mockCommitCopilotUsageReservation).toHaveBeenCalledWith({ + userId: 'user-1', + workflowId: undefined, + operation: expect.any(Function), + }) + expect(mockMarkMessageAsProcessed).not.toHaveBeenCalled() + }) + + it('does not mirror hosted Copilot completion reports on hosted Studio', async () => { + mockIsHosted.mockReturnValue(true) + + const { mirrorLocalCopilotCompletionUsageReports } = await import( + '@/lib/copilot/completion-usage-billing' + ) + await mirrorLocalCopilotCompletionUsageReports({ + userId: 'user-1', + reports: [ + { + kind: 'completion', + model: 'gpt-5.4', + completionId: 'hosted-completion-1', + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + ], + }) + + expect(mockAccrueUserUsageCost).not.toHaveBeenCalled() + expect(mockCommitCopilotUsageReservation).not.toHaveBeenCalled() + }) + it('does not double-bill duplicate completion ids', async () => { mockHasProcessedMessage.mockResolvedValue(true) @@ -1089,6 +951,12 @@ describe('Copilot Usage API - Completion', () => { }) expect(mockAccrueUserUsageCost).not.toHaveBeenCalled() expect(mockMarkMessageAsProcessed).not.toHaveBeenCalled() + expect(mockCommitCopilotUsageReservation).toHaveBeenCalledWith({ + userId: 'user-1', + workflowId: undefined, + reservationId: 'reservation-1', + operation: expect.any(Function), + }) expect(mockReleaseCopilotUsageReservation).toHaveBeenCalledWith({ reservationId: 'reservation-1', }) @@ -1125,6 +993,31 @@ describe('Copilot Usage API - Completion', () => { }) }) + it('does not release reservations for malformed completion commits', async () => { + const request = new NextRequest('http://localhost:3000/api/copilot/usage', { + method: 'POST', + body: JSON.stringify({ + action: 'commit', + kind: 'completion', + userId: 'user-1', + reservationId: 'reservation-1', + usage: { + prompt_tokens: 100, + completion_tokens: 25, + total_tokens: 125, + }, + }), + headers: { 'Content-Type': 'application/json' }, + }) + + const { POST } = await import('@/app/api/copilot/usage/route') + const response = await POST(request) + + expect(response.status).toBe(400) + expect(mockAccrueUserUsageCost).not.toHaveBeenCalled() + expect(mockReleaseCopilotUsageReservation).not.toHaveBeenCalled() + }) + it('releases the reservation when completion billing is disabled', async () => { mockIsBillingEnabledForRuntime.mockResolvedValue(false) diff --git a/apps/tradinggoose/app/api/copilot/usage/route.ts b/apps/tradinggoose/app/api/copilot/usage/route.ts index c808a23fa..4ec4441c0 100644 --- a/apps/tradinggoose/app/api/copilot/usage/route.ts +++ b/apps/tradinggoose/app/api/copilot/usage/route.ts @@ -1,29 +1,23 @@ -import { sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' -import { getPersonalEffectiveSubscription } from '@/lib/billing/core/subscription' import { isBillingEnabledForRuntime } from '@/lib/billing/settings' -import { getTierCopilotCostMultiplier } from '@/lib/billing/tiers' -import { accrueUserUsageCost } from '@/lib/billing/usage-accrual' -import { resolveWorkflowBillingContext } from '@/lib/billing/workspace-billing' import { - adjustCopilotUsageReservation, - releaseCopilotUsageReservation, - reserveCopilotUsage, -} from '@/lib/copilot/usage-reservations' + calculateCopilotReservationUsdFromEstimate, + recordCopilotCompletionUsage, +} from '@/lib/copilot/completion-usage-billing' import { COPILOT_RUNTIME_MODELS } from '@/lib/copilot/runtime-models' import { COPILOT_RUNTIME_PROVIDER_IDS } from '@/lib/copilot/runtime-provider' import { buildCopilotRuntimeProviderConfig } from '@/lib/copilot/runtime-provider.server' +import { + commitCopilotUsageReservation, + releaseCopilotUsageReservation, + reserveCopilotUsage, +} from '@/lib/copilot/usage-reservations' import { checkInternalApiKey } from '@/lib/copilot/utils' -import { isHosted } from '@/lib/environment' import { createLogger } from '@/lib/logs/console/logger' -import { hasProcessedMessage, markMessageAsProcessed } from '@/lib/redis' import { getCopilotApiUrl, proxyCopilotRequest } from '@/app/api/copilot/proxy' -import { calculateCost } from '@/providers/ai/utils' -const BILLING_EVENT_TTL_SECONDS = 60 * 60 * 24 * 30 // 30 days -const DEFAULT_ESTIMATED_RESERVATION_USD = 1 const BILLING_DISABLED_RESERVATION_ID = 'billing-disabled' const logger = createLogger('CopilotUsageAPI') @@ -32,11 +26,8 @@ const ContextUsageRequestSchema = z.object({ conversationId: z.string(), model: z.enum(COPILOT_RUNTIME_MODELS), workflowId: z.string().optional(), + workspaceId: z.string().optional(), provider: z.enum(COPILOT_RUNTIME_PROVIDER_IDS).optional(), - bill: z.boolean().optional(), - assistantMessageId: z.string().optional(), - billingModel: z.string().optional(), - userId: z.string().optional(), }) const UsageEstimateSchema = z.object({ @@ -53,39 +44,20 @@ const ReserveUsageUsdRequestSchema = z.object({ reason: z.string().min(1).optional(), }) -const ReserveUsageEstimatedRequestSchema = z.object({ - action: z.literal('reserve'), - userId: z.string().min(1, 'userId is required'), - workflowId: z.string().min(1).optional(), - reason: z.string().min(1).optional(), -}).merge(UsageEstimateSchema) +const ReserveUsageEstimatedRequestSchema = z + .object({ + action: z.literal('reserve'), + userId: z.string().min(1, 'userId is required'), + workflowId: z.string().min(1).optional(), + reason: z.string().min(1).optional(), + }) + .merge(UsageEstimateSchema) const ReserveUsageRequestSchema = z.union([ ReserveUsageUsdRequestSchema, ReserveUsageEstimatedRequestSchema, ]) -const AdjustUsageRequestSchema = z.object({ - action: z.literal('adjust'), - reservationId: z.string().min(1, 'reservationId is required'), - userId: z.string().min(1, 'userId is required'), - workflowId: z.string().min(1).optional(), - reason: z.string().min(1).optional(), -}).merge(UsageEstimateSchema) - -const ContextCommitRequestSchema = z.object({ - action: z.literal('commit'), - kind: z.literal('context'), - conversationId: z.string(), - model: z.enum(COPILOT_RUNTIME_MODELS), - workflowId: z.string().optional(), - provider: z.enum(COPILOT_RUNTIME_PROVIDER_IDS).optional(), - assistantMessageId: z.string().min(1, 'assistantMessageId is required'), - billingModel: z.string().optional(), - userId: z.string().min(1, 'userId is required'), - reservationId: z.string().min(1).optional(), -}) - const CompletionCommitRequestSchema = z.object({ action: z.literal('commit'), kind: z.literal('completion'), @@ -103,323 +75,15 @@ const ReleaseUsageRequestSchema = z.object({ reservationId: z.string().min(1, 'reservationId is required'), }) -interface TokenMetrics { - promptTokens: number - completionTokens: number - totalTokens: number -} - -type UsageBillingResult = - | { - billed: true - duplicate: false - cost: number - tokens: number - model: string - } - | { - billed: false - duplicate: true - } - | { - billed: false - duplicate?: false - reason: 'billing_disabled' | 'no_token_metrics' | 'zero_cost' | 'ledger_not_found' - } - -function readNumber(value: unknown): number | undefined { - if (typeof value === 'number' && Number.isFinite(value)) { - return value - } - if (typeof value === 'string') { - const parsed = Number.parseFloat(value) - return Number.isFinite(parsed) ? parsed : undefined - } - return undefined -} - -function pickNumber(source: any, keys: string[]): number | undefined { - if (!source || typeof source !== 'object') return undefined - for (const key of keys) { - const candidate = readNumber(source[key]) - if (candidate !== undefined) { - return candidate - } - } - return undefined -} - -function extractTokenMetrics(usage: any): TokenMetrics | null { - const sources = [usage, usage?.tokenUsage, usage?.tokens, usage?.usageDetails] - - let promptTokens: number | undefined - let completionTokens: number | undefined - let totalTokens: number | undefined - - for (const src of sources) { - if (promptTokens === undefined) { - promptTokens = pickNumber(src, [ - 'prompt_tokens', - 'promptTokens', - 'input_tokens', - 'inputTokens', - 'prompt', - ]) - } - if (completionTokens === undefined) { - completionTokens = pickNumber(src, [ - 'completion_tokens', - 'completionTokens', - 'output_tokens', - 'outputTokens', - 'completion', - ]) - } - if (totalTokens === undefined) { - totalTokens = pickNumber(src, [ - 'total_tokens', - 'totalTokens', - 'tokens', - 'token_count', - 'total', - ]) - } - } - - if (totalTokens === undefined) { - totalTokens = readNumber(usage?.tokensUsed) ?? readNumber(usage?.usage) - } - - if (completionTokens === undefined) { - completionTokens = 0 - } - - if (totalTokens !== undefined && promptTokens === undefined) { - promptTokens = totalTokens - completionTokens - } - - if (promptTokens === undefined || totalTokens === undefined) { - return null - } - - const normalizedPrompt = Math.max(0, Math.round(promptTokens)) - const normalizedCompletion = Math.max(0, Math.round(completionTokens ?? 0)) - const normalizedTotal = Math.max( - 0, - Math.round(totalTokens ?? normalizedPrompt + normalizedCompletion) - ) - - if (normalizedTotal <= 0 || (normalizedPrompt === 0 && normalizedCompletion === 0)) { - return null - } - - return { - promptTokens: normalizedPrompt, - completionTokens: normalizedCompletion, - totalTokens: normalizedTotal, - } -} - -function normalizeModelForBilling(model: string): string { - const base = model.includes('/') ? model.split('/').pop() || model : model - return base.toLowerCase() -} - -async function recordBilledUsage(params: { - userId: string - workflowId?: string - usage: any - billingModel: string - remoteModel?: string | null - billingKeyPrefix: 'copilot-billing' | 'copilot-completion-billing' - billingKeyId?: string | null - reason: 'copilot_context_usage' | 'copilot_completion_usage' -}): Promise { - const { userId, workflowId, usage, billingModel, remoteModel, billingKeyPrefix, billingKeyId, reason } = - params - - const metrics = extractTokenMetrics(usage) - if (!metrics) { - logger.info('Skipping copilot billing - no token metrics available', { - billingKeyPrefix, - billingKeyId, - reason, - }) - return { billed: false, reason: 'no_token_metrics' } - } - - const billingKey = billingKeyId ? `${billingKeyPrefix}:${billingKeyId}` : null - if (billingKey && (await hasProcessedMessage(billingKey))) { - logger.info('Copilot billing already processed', { billingKey, reason }) - return { billed: false, duplicate: true } - } - - const { costUsd: costToAdd, normalizedModel, billingContext } = await calculateCopilotCostUsd({ - userId, - workflowId, - billingModel, - remoteModel, - promptTokens: metrics.promptTokens, - completionTokens: metrics.completionTokens, - }) - if (costToAdd <= 0) { - logger.info('Skipping copilot billing - calculated cost is zero', { - userId, - workflowId, - billingKeyId, - model: normalizedModel, - reason, - }) - return { billed: false, reason: 'zero_cost' } - } - - const extraUpdates: Record = { - totalCopilotCost: sql`total_copilot_cost + ${costToAdd}`, - currentPeriodCopilotCost: sql`current_period_copilot_cost + ${costToAdd}`, - totalCopilotCalls: sql`total_copilot_calls + 1`, - } - - if (metrics.totalTokens > 0) { - extraUpdates.totalCopilotTokens = sql`total_copilot_tokens + ${metrics.totalTokens}` - } - - const didAccrue = await accrueUserUsageCost({ - userId, - workflowId, - cost: costToAdd, - extraUpdates, - reason, - }) - - if (!didAccrue) { - logger.warn('Copilot billing skipped - ledger record not found', { - userId, - workflowId, - billingKeyId, - reason, - }) - return { billed: false, reason: 'ledger_not_found' } - } - - if (billingKey) { - await markMessageAsProcessed(billingKey, BILLING_EVENT_TTL_SECONDS) - } - - logger.info('Copilot billing recorded', { - userId, - billingUserId: billingContext?.billingUserId ?? userId, - workflowId, - billingKeyId, - cost: costToAdd, - tokens: metrics.totalTokens, - model: normalizedModel, - reason, - }) - - return { - billed: true, - duplicate: false, - cost: costToAdd, - tokens: metrics.totalTokens, - model: normalizedModel, - } -} - -async function resolveEffectiveCopilotTier(params: { - userId: string - workflowId?: string -}): Promise<{ - effectiveTier: any - billingContext: Awaited> | null -}> { - const billingContext = params.workflowId - ? await resolveWorkflowBillingContext({ - workflowId: params.workflowId, - actorUserId: params.userId, - }) - : null - const effectiveTier = params.workflowId - ? billingContext?.subscription?.tier ?? null - : (await getPersonalEffectiveSubscription(params.userId))?.tier ?? null - - if (!effectiveTier) { - throw new Error( - params.workflowId - ? `No active workflow subscription tier found for billed copilot usage on workflow ${params.workflowId}` - : `No active personal subscription tier found for billed copilot usage for user ${params.userId}` - ) - } - - return { - effectiveTier, - billingContext, - } -} - -async function calculateCopilotCostUsd(params: { - userId: string - workflowId?: string - billingModel: string - remoteModel?: string | null - promptTokens: number - completionTokens: number - fallbackUsd?: number -}): Promise<{ - costUsd: number - normalizedModel: string - billingContext: Awaited> | null -}> { - const modelToUse = - typeof params.remoteModel === 'string' && params.remoteModel.length > 0 - ? params.remoteModel - : params.billingModel - const normalizedModel = normalizeModelForBilling(modelToUse) - const costResult = calculateCost( - normalizedModel, - params.promptTokens, - params.completionTokens, - false - ) - const { effectiveTier, billingContext } = await resolveEffectiveCopilotTier({ - userId: params.userId, - workflowId: params.workflowId, - }) - const rawCostUsd = Number(costResult.total || 0) * getTierCopilotCostMultiplier(effectiveTier) - - return { - costUsd: rawCostUsd > 0 ? rawCostUsd : params.fallbackUsd ?? 0, - normalizedModel, - billingContext, - } -} - -async function calculateReservationUsdFromEstimate(params: { - userId: string - workflowId?: string - model: string - estimatedPromptTokens: number - reservedCompletionTokens: number -}): Promise { - const { costUsd } = await calculateCopilotCostUsd({ - userId: params.userId, - workflowId: params.workflowId, - billingModel: params.model, - promptTokens: params.estimatedPromptTokens, - completionTokens: params.reservedCompletionTokens, - fallbackUsd: DEFAULT_ESTIMATED_RESERVATION_USD, - }) - - return costUsd -} - async function fetchContextUsageFromCopilot(params: { conversationId: string model: z.infer['model'] workflowId?: string + workspaceId?: string provider?: z.infer['provider'] userId: string }) { - const { conversationId, model, workflowId, provider, userId } = params + const { conversationId, model, workflowId, workspaceId, provider, userId } = params const { providerConfig } = await buildCopilotRuntimeProviderConfig({ model, provider, @@ -430,6 +94,7 @@ async function fetchContextUsageFromCopilot(params: { model, userId, ...(workflowId ? { workflowId } : {}), + ...(workspaceId ? { workspaceId } : {}), provider: providerConfig, } @@ -445,14 +110,11 @@ async function fetchContextUsageFromCopilot(params: { } async function handleContextUsage( - req: NextRequest, payload: z.infer ): Promise { - const { conversationId, model, workflowId, provider, bill, assistantMessageId, billingModel } = - payload - const internalAuth = checkInternalApiKey(req) - const session = !internalAuth.success ? await getSession() : null - const userId = internalAuth.success ? payload.userId : session?.user?.id + const { conversationId, model, workflowId, workspaceId, provider } = payload + const session = await getSession() + const userId = session?.user?.id if (!userId) { logger.warn('[Usage API] No session/user ID for context usage') @@ -463,6 +125,7 @@ async function handleContextUsage( conversationId, model, workflowId, + workspaceId, provider, userId, }) @@ -480,76 +143,10 @@ async function handleContextUsage( } const data = await simAgentResponse.json() - - const shouldBill = Boolean(bill && assistantMessageId && !internalAuth.success && !isHosted) - if (!shouldBill) { - return NextResponse.json(data) - } - - if (!(await isBillingEnabledForRuntime())) { - return NextResponse.json({ - ...data, - billing: { billed: false, reason: 'billing_disabled' }, - }) - } - - try { - const billing = await recordBilledUsage({ - userId, - workflowId, - usage: data, - billingModel: billingModel || model, - remoteModel: data?.model, - billingKeyPrefix: 'copilot-billing', - billingKeyId: assistantMessageId, - reason: 'copilot_context_usage', - }) - return NextResponse.json({ - ...data, - billing, - }) - } catch (billingError) { - logger.error('Failed to bill copilot context usage', { - error: billingError, - conversationId, - assistantMessageId, - }) - return NextResponse.json({ - ...data, - billing: { billed: false, reason: 'ledger_not_found' }, - }) - } + return NextResponse.json(data) } -async function releaseCommittedReservation(reservationId?: string): Promise { - if (!reservationId) return - if (reservationId === BILLING_DISABLED_RESERVATION_ID) { - return - } - - await releaseCopilotUsageReservation({ reservationId }).catch((error) => { - logger.warn('Failed to release copilot usage reservation after commit', { - reservationId, - error: error instanceof Error ? error.message : String(error), - }) - }) -} - -async function withCommittedReservationRelease( - reservationId: string | undefined, - operation: () => Promise -): Promise { - try { - return await operation() - } finally { - await releaseCommittedReservation(reservationId) - } -} - -function buildBillingDisabledReservation(params: { - userId: string - reservationId?: string -}) { +function buildBillingDisabledReservation(params: { userId: string; reservationId?: string }) { return { allowed: true, status: 200, @@ -580,7 +177,7 @@ async function handleReserveUsage( const requestedUsd = 'requestedUsd' in payload ? payload.requestedUsd - : await calculateReservationUsdFromEstimate({ + : await calculateCopilotReservationUsdFromEstimate({ userId: payload.userId, workflowId: payload.workflowId, model: payload.model, @@ -598,133 +195,35 @@ async function handleReserveUsage( return NextResponse.json(result, { status: result.status }) } -async function handleAdjustUsage( - req: NextRequest, - payload: z.infer +async function handleCompletionCommit( + payload: z.infer ): Promise { - const auth = checkInternalApiKey(req) - if (!auth.success) { - return new NextResponse(null, { status: 401 }) - } - - if (!(await isBillingEnabledForRuntime())) { - return NextResponse.json( - buildBillingDisabledReservation({ - userId: payload.userId, - reservationId: payload.reservationId, - }) - ) - } - - const requestedUsd = await calculateReservationUsdFromEstimate({ - userId: payload.userId, - workflowId: payload.workflowId, - model: payload.model, - estimatedPromptTokens: payload.estimatedPromptTokens, - reservedCompletionTokens: payload.reservedCompletionTokens, - }) - - const result = await adjustCopilotUsageReservation({ - reservationId: payload.reservationId, + return await commitCopilotUsageReservation({ userId: payload.userId, workflowId: payload.workflowId, - requestedUsd, - reason: payload.reason, - }) - - return NextResponse.json(result, { status: result.status }) -} - -async function handleContextCommit( - req: NextRequest, - payload: z.infer -): Promise { - const auth = checkInternalApiKey(req) - if (!auth.success) { - return new NextResponse(null, { status: 401 }) - } - - return withCommittedReservationRelease(payload.reservationId, async () => { - const simAgentResponse = await fetchContextUsageFromCopilot({ - conversationId: payload.conversationId, - model: payload.model, - workflowId: payload.workflowId, - provider: payload.provider, - userId: payload.userId, - }) - - if (!simAgentResponse.ok) { - const errorText = await simAgentResponse.text().catch(() => '') - logger.warn('[Usage API] TradingGoose agent request failed during commit', { - status: simAgentResponse.status, - error: errorText, - reservationId: payload.reservationId, - }) - return NextResponse.json( - { error: 'Failed to fetch context usage from copilot' }, - { status: simAgentResponse.status } - ) - } - - const data = await simAgentResponse.json() + reservationId: + payload.reservationId === BILLING_DISABLED_RESERVATION_ID ? undefined : payload.reservationId, + operation: async () => { + if (!(await isBillingEnabledForRuntime())) { + return NextResponse.json({ + success: true, + billing: { billed: false, reason: 'billing_disabled' }, + }) + } - if (!(await isBillingEnabledForRuntime())) { - return NextResponse.json({ - ...data, - billing: { billed: false, reason: 'billing_disabled' }, + const billing = await recordCopilotCompletionUsage({ + userId: payload.userId, + workflowId: payload.workflowId, + usage: payload.usage, + billingModel: payload.model, + billingKeyId: payload.completionId, }) - } - - const billing = await recordBilledUsage({ - userId: payload.userId, - workflowId: payload.workflowId, - usage: data, - billingModel: payload.billingModel || payload.model, - remoteModel: data?.model, - billingKeyPrefix: 'copilot-billing', - billingKeyId: payload.assistantMessageId, - reason: 'copilot_context_usage', - }) - - return NextResponse.json({ - ...data, - billing, - }) - }) -} - -async function handleCompletionCommit( - req: NextRequest, - payload: z.infer -): Promise { - const auth = checkInternalApiKey(req) - if (!auth.success) { - return new NextResponse(null, { status: 401 }) - } - return withCommittedReservationRelease(payload.reservationId, async () => { - if (!(await isBillingEnabledForRuntime())) { return NextResponse.json({ success: true, - billing: { billed: false, reason: 'billing_disabled' }, + billing, }) - } - - const billing = await recordBilledUsage({ - userId: payload.userId, - workflowId: payload.workflowId, - usage: payload.usage, - billingModel: payload.model, - remoteModel: payload.remoteModel, - billingKeyPrefix: 'copilot-completion-billing', - billingKeyId: payload.completionId, - reason: 'copilot_completion_usage', - }) - - return NextResponse.json({ - success: true, - billing, - }) + }, }) } @@ -753,7 +252,7 @@ async function handleReleaseUsage( /** * POST /api/copilot/usage - * Unified copilot usage endpoint for context inspection/billing and raw completion billing. + * Unified copilot usage endpoint for context inspection, reservation control, and completion billing. */ export async function POST(req: NextRequest) { try { @@ -769,7 +268,8 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }) } - const action = body && typeof body === 'object' ? (body as Record).action : null + const action = + body && typeof body === 'object' ? (body as Record).action : null if (action === 'reserve') { const parsed = ReserveUsageRequestSchema.safeParse(body) if (!parsed.success) { @@ -785,48 +285,25 @@ export async function POST(req: NextRequest) { return await handleReserveUsage(req, parsed.data) } - if (action === 'adjust') { - const parsed = AdjustUsageRequestSchema.safeParse(body) - if (!parsed.success) { - logger.warn('Invalid copilot usage adjust request', { errors: parsed.error.errors }) - return NextResponse.json( - { - error: 'Invalid request body', - details: parsed.error.errors, - }, - { status: 400 } - ) + if (action === 'commit') { + const auth = checkInternalApiKey(req) + if (!auth.success) { + return new NextResponse(null, { status: 401 }) } - return await handleAdjustUsage(req, parsed.data) - } - if (action === 'commit') { - const kind = body && typeof body === 'object' ? (body as Record).kind : null - const parsed = - kind === 'context' - ? ContextCommitRequestSchema.safeParse(body) - : kind === 'completion' - ? CompletionCommitRequestSchema.safeParse(body) - : null - - if (!parsed || !parsed.success) { - logger.warn('Invalid copilot usage commit request', { - errors: parsed && !parsed.success ? parsed.error.errors : [{ message: 'Invalid commit kind' }], - }) + const parsed = CompletionCommitRequestSchema.safeParse(body) + if (!parsed.success) { + logger.warn('Invalid copilot usage commit request', { errors: parsed.error.errors }) return NextResponse.json( { error: 'Invalid request body', - details: parsed && !parsed.success ? parsed.error.errors : [{ message: 'Invalid commit kind' }], + details: parsed.error.errors, }, { status: 400 } ) } - if (parsed.data.kind === 'context') { - return await handleContextCommit(req, parsed.data) - } - - return await handleCompletionCommit(req, parsed.data) + return await handleCompletionCommit(parsed.data) } if (action === 'release') { @@ -857,7 +334,7 @@ export async function POST(req: NextRequest) { ) } - return await handleContextUsage(req, parsed.data) + return await handleContextUsage(parsed.data) } catch (error) { logger.error('Failed to process copilot usage request', { error }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) diff --git a/apps/tradinggoose/app/api/files/serve/[...path]/route.test.ts b/apps/tradinggoose/app/api/files/serve/[...path]/route.test.ts index 42718c52a..8e29854ed 100644 --- a/apps/tradinggoose/app/api/files/serve/[...path]/route.test.ts +++ b/apps/tradinggoose/app/api/files/serve/[...path]/route.test.ts @@ -60,7 +60,6 @@ describe('File Serve API Route', () => { }, getEnv: vi.fn((key: string) => { if (key === 'NEXT_PUBLIC_APP_URL') return 'https://app.tradinggoose.ai' - if (key === 'NEXT_PUBLIC_IS_PREVIEW_DEVELOPMENT') return 'false' return undefined }), })) diff --git a/apps/tradinggoose/app/api/folders/[id]/route.test.ts b/apps/tradinggoose/app/api/folders/[id]/route.test.ts index 9054195ae..c0f1b2db5 100644 --- a/apps/tradinggoose/app/api/folders/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/folders/[id]/route.test.ts @@ -15,6 +15,7 @@ import { interface FolderDbMockOptions { folderLookupResult?: any + childFoldersResult?: any[] updateResult?: any[] throwError?: boolean circularCheckResults?: any[] @@ -41,10 +42,12 @@ describe('Individual Folder API Route', () => { const { mockAuthenticatedUser, mockUnauthenticated } = mockAuth(TEST_USER) const mockGetUserEntityPermissions = vi.fn() + const mockRefreshWorkflowList = vi.fn() function createFolderDbMock(options: FolderDbMockOptions = {}) { const { folderLookupResult = mockFolder, + childFoldersResult = [], updateResult = [{ ...mockFolder, name: 'Updated Folder' }], throwError = false, circularCheckResults = [], @@ -74,6 +77,9 @@ describe('Individual Folder API Route', () => { const result = circularCheckResults[index] ? [circularCheckResults[index]] : [] return Promise.resolve(callback(result)) } + if (callCount === 2) { + return Promise.resolve(callback(childFoldersResult)) + } return Promise.resolve(callback([])) }), })), @@ -97,6 +103,9 @@ describe('Individual Folder API Route', () => { select: mockSelect, update: mockUpdate, delete: mockDelete, + transaction: vi.fn(async (callback) => + callback({ update: mockUpdate, delete: mockDelete }) + ), }, mocks: { select: mockSelect, @@ -112,10 +121,14 @@ describe('Individual Folder API Route', () => { setupCommonApiMocks() mockGetUserEntityPermissions.mockResolvedValue('admin') + mockRefreshWorkflowList.mockResolvedValue(undefined) vi.doMock('@/lib/permissions/utils', () => ({ getUserEntityPermissions: mockGetUserEntityPermissions, })) + vi.doMock('@/lib/workflows/db-helpers', () => ({ + refreshWorkflowList: mockRefreshWorkflowList, + })) }) afterEach(() => { @@ -417,14 +430,15 @@ describe('Individual Folder API Route', () => { }) describe('DELETE /api/folders/[id]', () => { - it('should delete folder and all contents successfully', async () => { + it('should delete the folder and move direct child folders and workflows up one level', async () => { mockAuthenticatedUser() const dbMock = createFolderDbMock({ - folderLookupResult: mockFolder, + folderLookupResult: { ...mockFolder, parentId: 'parent-folder' }, + childFoldersResult: [{ id: 'child-folder' }], + updateResult: [{ id: 'workflow-1' }], }) - // Mock the recursive deletion function vi.doMock('@tradinggoose/db', () => dbMock) const req = createMockRequest('DELETE') @@ -438,7 +452,13 @@ describe('Individual Folder API Route', () => { const data = await response.json() expect(data).toHaveProperty('success', true) - expect(data).toHaveProperty('deletedItems') + expect(data).toMatchObject({ + deletedFolderId: 'folder-1', + parentId: 'parent-folder', + movedFolders: 1, + movedWorkflows: 1, + }) + expect(mockRefreshWorkflowList).toHaveBeenCalledWith('workspace-123') }) it('should return 401 for unauthenticated delete requests', async () => { @@ -506,6 +526,7 @@ describe('Individual Folder API Route', () => { const dbMock = createFolderDbMock({ folderLookupResult: mockFolder, + updateResult: [], }) vi.doMock('@tradinggoose/db', () => dbMock) diff --git a/apps/tradinggoose/app/api/folders/[id]/route.ts b/apps/tradinggoose/app/api/folders/[id]/route.ts index 0c69f764a..78b88527d 100644 --- a/apps/tradinggoose/app/api/folders/[id]/route.ts +++ b/apps/tradinggoose/app/api/folders/[id]/route.ts @@ -5,6 +5,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { getUserEntityPermissions } from '@/lib/permissions/utils' +import { refreshWorkflowList } from '@/lib/workflows/db-helpers' const logger = createLogger('FoldersIDAPI') @@ -83,7 +84,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ } } -// DELETE - Delete a folder and all its contents +// DELETE - Delete one folder and promote its direct children one level up. export async function DELETE( request: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -121,17 +122,57 @@ export async function DELETE( ) } - // Recursively delete folder and all its contents - const deletionStats = await deleteFolderRecursively(id, existingFolder.workspaceId) + const parentId = existingFolder.parentId ?? null + const childFolders = await db + .select({ id: workflowFolder.id }) + .from(workflowFolder) + .where( + and( + eq(workflowFolder.parentId, id), + eq(workflowFolder.workspaceId, existingFolder.workspaceId) + ) + ) + + let movedWorkflows: Array<{ id: string }> = [] + + await db.transaction(async (tx) => { + const now = new Date() + if (childFolders.length > 0) { + await tx + .update(workflowFolder) + .set({ parentId, updatedAt: now }) + .where( + and( + eq(workflowFolder.parentId, id), + eq(workflowFolder.workspaceId, existingFolder.workspaceId) + ) + ) + } - logger.info('Deleted folder and all contents:', { + movedWorkflows = await tx + .update(workflow) + .set({ folderId: parentId, updatedAt: now }) + .where(and(eq(workflow.workspaceId, existingFolder.workspaceId), eq(workflow.folderId, id))) + .returning({ id: workflow.id }) + + await tx.delete(workflowFolder).where(eq(workflowFolder.id, id)) + }) + + await refreshWorkflowList(existingFolder.workspaceId) + + logger.info('Deleted folder and promoted direct children:', { id, - deletionStats, + parentId, + movedFolders: childFolders.length, + movedWorkflows: movedWorkflows.length, }) return NextResponse.json({ success: true, - deletedItems: deletionStats, + deletedFolderId: id, + parentId, + movedFolders: childFolders.length, + movedWorkflows: movedWorkflows.length, }) } catch (error) { logger.error('Error deleting folder:', { error }) @@ -139,49 +180,6 @@ export async function DELETE( } } -// Helper function to recursively delete a folder and all its contents -async function deleteFolderRecursively( - folderId: string, - workspaceId: string -): Promise<{ folders: number; workflows: number }> { - const stats = { folders: 0, workflows: 0 } - - // Get all child folders first (workspace-scoped, not user-scoped) - const childFolders = await db - .select({ id: workflowFolder.id }) - .from(workflowFolder) - .where(and(eq(workflowFolder.parentId, folderId), eq(workflowFolder.workspaceId, workspaceId))) - - // Recursively delete child folders - for (const childFolder of childFolders) { - const childStats = await deleteFolderRecursively(childFolder.id, workspaceId) - stats.folders += childStats.folders - stats.workflows += childStats.workflows - } - - // Delete all workflows in this folder (workspace-scoped, not user-scoped) - // The database cascade will handle deleting related workflow_blocks, workflow_edges, workflow_subflows - const workflowsInFolder = await db - .select({ id: workflow.id }) - .from(workflow) - .where(and(eq(workflow.folderId, folderId), eq(workflow.workspaceId, workspaceId))) - - if (workflowsInFolder.length > 0) { - await db - .delete(workflow) - .where(and(eq(workflow.folderId, folderId), eq(workflow.workspaceId, workspaceId))) - - stats.workflows += workflowsInFolder.length - } - - // Delete this folder - await db.delete(workflowFolder).where(eq(workflowFolder.id, folderId)) - - stats.folders += 1 - - return stats -} - // Helper function to check for circular references async function checkForCircularReference(folderId: string, parentId: string): Promise { let currentParentId: string | null = parentId diff --git a/apps/tradinggoose/app/api/function/e2b-execution.ts b/apps/tradinggoose/app/api/function/e2b-execution.ts index 038954502..91f3f9f9c 100644 --- a/apps/tradinggoose/app/api/function/e2b-execution.ts +++ b/apps/tradinggoose/app/api/function/e2b-execution.ts @@ -1,9 +1,11 @@ import { executeInE2B, isE2BWarmSandboxLimitError } from '@/lib/execution/e2b' import { CodeLanguage } from '@/lib/execution/languages' import { resolveExecutionRuntimeConfig } from '@/lib/execution/runtime-config' -import { DEFAULT_INDICATOR_RUNTIME_MANIFEST } from '@/lib/indicators/default/runtime' import { buildPineTSFunctionIndicatorRuntimePrologue } from '@/lib/indicators/execution/e2b-script-builder' -import { FUNCTION_INDICATOR_USAGE_HINT } from '@/lib/indicators/execution/function-indicator-runtime' +import { + type FunctionIndicatorRuntimeManifest, + FUNCTION_INDICATOR_USAGE_HINT, +} from '@/lib/indicators/execution/function-indicator-runtime' import { formatE2BError } from './error-formatting' import { executeFunctionInLocalVm } from './local-execution' import { extractJavaScriptImports } from './typescript-utils' @@ -16,6 +18,7 @@ type ExecuteFunctionInE2BArgs = { executionParams: Record envVars: Record contextVariables: Record + indicatorRuntimeManifest: FunctionIndicatorRuntimeManifest isCustomTool: boolean timeout: number e2bTemplate?: string @@ -31,6 +34,7 @@ export const executeFunctionInE2B = async ({ executionParams, envVars, contextVariables, + indicatorRuntimeManifest, isCustomTool, timeout, e2bTemplate, @@ -70,7 +74,7 @@ export const executeFunctionInE2B = async ({ } const indicatorRuntimePrologue = buildPineTSFunctionIndicatorRuntimePrologue({ - manifest: DEFAULT_INDICATOR_RUNTIME_MANIFEST, + manifest: indicatorRuntimeManifest, usageHint: FUNCTION_INDICATOR_USAGE_HINT, }) prologue += `${indicatorRuntimePrologue}\n` @@ -145,6 +149,7 @@ type ExecuteFunctionWithRuntimeGateArgs = { executionParams: Record envVars: Record contextVariables: Record + indicatorRuntimeManifest: FunctionIndicatorRuntimeManifest timeout: number isCustomTool: boolean e2bUserScope?: string @@ -199,6 +204,7 @@ export const executeFunctionWithRuntimeGate = async ({ executionParams, envVars, contextVariables, + indicatorRuntimeManifest, timeout, isCustomTool, e2bUserScope, @@ -219,6 +225,7 @@ export const executeFunctionWithRuntimeGate = async ({ executionParams, envVars, contextVariables, + indicatorRuntimeManifest, isCustomTool, timeout, e2bTemplate: runtimeConfig.e2bTemplate ?? undefined, @@ -270,6 +277,7 @@ export const executeFunctionWithRuntimeGate = async ({ executionParams, envVars, contextVariables, + indicatorRuntimeManifest, isCustomTool, ownerKey: e2bUserScope ? `scope:${e2bUserScope}` : undefined, onStdout, diff --git a/apps/tradinggoose/app/api/function/execute/route.test.ts b/apps/tradinggoose/app/api/function/execute/route.test.ts index 94a7a8505..355fc1db3 100644 --- a/apps/tradinggoose/app/api/function/execute/route.test.ts +++ b/apps/tradinggoose/app/api/function/execute/route.test.ts @@ -5,10 +5,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createMockRequest } from '@/app/api/__test-utils__/utils' const checkInternalAuthMock = vi.fn() -const checkServerSideUsageLimitsMock = vi.fn() -const executeFunctionWithRuntimeGateMock = vi.fn() -const isBillingEnabledForRuntimeMock = vi.fn() -const accrueUserUsageCostMock = vi.fn() +const checkWorkspaceAccessMock = vi.fn() +const executeFunctionRequestMock = vi.fn() +const readWorkflowByIdMock = vi.fn() const loggerMock = { info: vi.fn(), error: vi.fn(), @@ -19,8 +18,6 @@ const loggerMock = { const workflowFunctionBody = (body: Record = {}) => ({ code: 'return "ok"', workflowId: 'workflow-1', - workspaceId: 'workspace-1', - usesParentExecutionConcurrencySlot: true, ...body, }) @@ -36,21 +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 }) + executeFunctionRequestMock.mockResolvedValue({ + statusCode: 200, + body: { + success: true, + output: { + result: 'ok', + stdout: 'stdout', + executionTime: 2400, + }, + }, }) - executeFunctionWithRuntimeGateMock.mockResolvedValue({ - engine: 'local_vm', - success: true, - result: 'ok', - stdout: 'stdout', - executionTime: 2400, - userCodeStartLine: 3, - }) - isBillingEnabledForRuntimeMock.mockResolvedValue(false) - accrueUserUsageCostMock.mockResolvedValue(true) vi.doMock('@/lib/auth/hybrid', () => ({ checkInternalAuth: checkInternalAuthMock, @@ -61,62 +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/permissions/utils', () => ({ + checkWorkspaceAccess: checkWorkspaceAccessMock, })) - vi.doMock('@/lib/billing/tiers', () => ({ - getTierFunctionExecutionMultiplier: vi.fn(() => 0.5), + vi.doMock('@/lib/function/execution', () => ({ + executeFunctionRequest: executeFunctionRequestMock, })) - 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/workflows/utils', () => ({ + readWorkflowById: readWorkflowByIdMock.mockResolvedValue({ + id: 'workflow-1', + workspaceId: 'workspace-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/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 () => { @@ -129,26 +79,48 @@ 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('rejects requests without parent workflow execution context', async () => { + it('accepts exactly one execution scope', async () => { const { POST } = await import('@/app/api/function/execute/route') - const response = await POST( + const workspaceResponse = await POST( + createMockRequest('POST', { + code: 'return "ok"', + workspaceId: 'workspace-1', + }) + ) + + 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', { code: 'return "ok"', workflowId: 'workflow-1', workspaceId: 'workspace-1', }) ) - const payload = await response.json() + const mixedScopePayload = await mixedScopeResponse.json() - expect(response.status).toBe(400) - expect(payload.success).toBe(false) - expect(payload.error).toBe('Function execution requires parent workflow execution context') - expect(executeFunctionWithRuntimeGateMock).not.toHaveBeenCalled() + expect(mixedScopeResponse.status).toBe(400) + expect(mixedScopePayload.error).toBe( + 'Function execution accepts either workflow or workspace context, not both' + ) + expect(executeFunctionRequestMock).toHaveBeenCalledOnce() }) - it('executes under workflow parent context without acquiring a standalone billing gate', async () => { + it('executes under workflow context', async () => { const { POST } = await import('@/app/api/function/execute/route') const response = await POST(createFunctionRequest()) const payload = await response.json() @@ -156,70 +128,86 @@ 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(executeFunctionWithRuntimeGateMock).toHaveBeenCalledOnce() + expect(checkWorkspaceAccessMock).toHaveBeenCalledWith('workspace-1', 'user-1') + expect(executeFunctionRequestMock).toHaveBeenCalledWith( + expect.objectContaining({ + code: 'return "ok"', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + userId: 'user-1', + requestId: 'request-1', + }) + ) + expect(executeFunctionRequestMock).toHaveBeenCalledOnce() }) - it('blocks before runtime when workflow usage is exceeded', async () => { - checkServerSideUsageLimitsMock.mockResolvedValueOnce({ - isExceeded: true, - currentUsage: 101, - limit: 100, - message: 'Usage limit exceeded', - }) + it('rejects workflow requests when workspace access is denied', async () => { + checkWorkspaceAccessMock.mockResolvedValueOnce({ hasAccess: false, canWrite: false }) const { POST } = await import('@/app/api/function/execute/route') const response = await POST(createFunctionRequest()) const payload = await response.json() - expect(response.status).toBe(402) + expect(response.status).toBe(403) expect(payload.success).toBe(false) - expect(payload.error).toBe('Usage limit exceeded') - expect(executeFunctionWithRuntimeGateMock).not.toHaveBeenCalled() + expect(payload.error).toBe('Access denied') + expect(executeFunctionRequestMock).not.toHaveBeenCalled() }) - it('accrues workflow-scoped function execution cost after runtime finishes', async () => { - isBillingEnabledForRuntimeMock.mockResolvedValueOnce(true) + it('rejects workspace-scoped function execution for read-only workspace members', async () => { + checkWorkspaceAccessMock.mockResolvedValueOnce({ hasAccess: true, canWrite: false }) const { POST } = await import('@/app/api/function/execute/route') - const response = await POST(createFunctionRequest()) + const response = await POST( + createMockRequest('POST', { + code: 'return "ok"', + workspaceId: 'workspace-1', + }) + ) + const payload = await response.json() - expect(response.status).toBe(200) - expect(accrueUserUsageCostMock).toHaveBeenCalledWith({ - userId: 'user-1', - workspaceId: 'workspace-1', - workflowId: 'workflow-1', - cost: 0.3, - reason: 'function_execution', - }) + expect(response.status).toBe(403) + expect(payload.success).toBe(false) + expect(payload.error).toBe('Access denied') + expect(executeFunctionRequestMock).not.toHaveBeenCalled() }) - it('keeps runtime success when post-run billing accrual fails', async () => { - isBillingEnabledForRuntimeMock.mockResolvedValueOnce(true) - accrueUserUsageCostMock.mockRejectedValueOnce(new Error('billing unavailable')) + 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') 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(response.status).toBe(402) + expect(payload.success).toBe(false) + expect(payload.error).toBe('Usage limit exceeded') + 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/function/execute/route.ts b/apps/tradinggoose/app/api/function/execute/route.ts index bb9d1cca2..644719caa 100644 --- a/apps/tradinggoose/app/api/function/execute/route.ts +++ b/apps/tradinggoose/app/api/function/execute/route.ts @@ -2,7 +2,9 @@ import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' import { executeFunctionRequest } from '@/lib/function/execution' import { createLogger } from '@/lib/logs/console/logger' +import { checkWorkspaceAccess } from '@/lib/permissions/utils' import { generateRequestId } from '@/lib/utils' +import { readWorkflowById } from '@/lib/workflows/utils' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' @@ -42,22 +44,39 @@ export async function POST(req: NextRequest) { } const body = await req.json() - const { workflowId, workspaceId } = body + const workflowId = typeof body.workflowId === 'string' ? body.workflowId.trim() : '' + const workspaceId = typeof body.workspaceId === 'string' ? body.workspaceId.trim() : '' - if ( - body.usesParentExecutionConcurrencySlot !== true || - typeof workflowId !== 'string' || - typeof workspaceId !== 'string' - ) { + if (!workflowId && !workspaceId) { return respondFailure( - 'Function execution requires parent workflow execution context', + 'Function execution requires workflow or workspace context', Date.now() - startTime, 400 ) } + if (workflowId && workspaceId) { + return respondFailure( + 'Function execution accepts either workflow or workspace context, not both', + Date.now() - startTime, + 400 + ) + } + + const workflow = workflowId ? await readWorkflowById(workflowId) : null + if (workflowId && !workflow?.workspaceId) { + return respondFailure('Workflow not found', Date.now() - startTime, 404) + } + + const executionWorkspaceId = workflow?.workspaceId ?? workspaceId + const access = await checkWorkspaceAccess(executionWorkspaceId, auth.userId) + if (!access.canWrite) { + return respondFailure('Access denied', Date.now() - startTime, 403) + } const result = await executeFunctionRequest({ ...body, + workflowId: workflow?.id, + workspaceId: executionWorkspaceId, userId: auth.userId, requestId, }) diff --git a/apps/tradinggoose/app/api/function/local-execution.ts b/apps/tradinggoose/app/api/function/local-execution.ts index 51d24ff57..facda6301 100644 --- a/apps/tradinggoose/app/api/function/local-execution.ts +++ b/apps/tradinggoose/app/api/function/local-execution.ts @@ -1,6 +1,9 @@ import { createContext, Script } from 'vm' import { withLocalVmSaturationLimit } from '@/lib/execution/local-saturation-limit' -import { createFunctionIndicatorRuntime } from '@/lib/indicators/execution/function-indicator-runtime' +import { + createFunctionIndicatorRuntime, + type FunctionIndicatorRuntimeManifest, +} from '@/lib/indicators/execution/function-indicator-runtime' import { validateProxyUrl } from '@/lib/security/input-validation' type LocalExecutionArgs = { @@ -10,6 +13,7 @@ type LocalExecutionArgs = { executionParams: Record envVars: Record contextVariables: Record + indicatorRuntimeManifest: FunctionIndicatorRuntimeManifest isCustomTool: boolean ownerKey?: string onStdout: (chunk: string) => void @@ -50,6 +54,7 @@ export const executeFunctionInLocalVm = async ({ executionParams, envVars, contextVariables, + indicatorRuntimeManifest, isCustomTool, ownerKey, onStdout, @@ -57,6 +62,7 @@ export const executeFunctionInLocalVm = async ({ onError, }: LocalExecutionArgs): Promise<{ result: unknown; userCodeStartLine: number }> => { const indicator = createFunctionIndicatorRuntime({ + manifest: indicatorRuntimeManifest, requestId, onWarn, }) 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 e9de87c8b..b80cfb58e 100644 --- a/apps/tradinggoose/app/api/indicators/custom/import/route.test.ts +++ b/apps/tradinggoose/app/api/indicators/custom/import/route.test.ts @@ -72,9 +72,10 @@ describe('Indicators import route', () => { indicators: [ { name: 'RSI Export Example', - color: '#3972F6', pineCode: "indicator('RSI Export Example')", - inputMeta: {}, + inputMeta: { + Stale: { title: 'Stale', type: 'string', defval: 'old' }, + }, }, ], }, @@ -93,9 +94,7 @@ describe('Indicators import route', () => { indicators: [ { name: 'RSI Export Example', - color: '#3972F6', 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 e904862bc..665b1fccb 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, + refreshEntityListSession, +} 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({ @@ -36,9 +42,7 @@ const IndicatorSchema = z.object({ z.object({ id: z.string().optional(), name: z.string().min(1, 'Indicator name is required'), - color: z.string().optional(), pineCode: z.string().default(''), - inputMeta: z.record(z.any()).optional(), }) ), }) @@ -47,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({ @@ -60,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 }) } @@ -147,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) { @@ -173,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 }) } @@ -233,11 +248,13 @@ 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 refreshEntityListSession('indicator', workspaceId) + await Promise.allSettled([deleteYjsSessionInSocketServer(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..d0a80ca5f 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', @@ -89,7 +68,6 @@ describe('indicator options route', () => { pineCode: 'trigger-capable', inputMeta: { Threshold: { title: 'Threshold', type: 'float', defval: 2.5 }, - Broken: { title: '' }, }, }, { @@ -102,17 +80,13 @@ describe('indicator options route', () => { }, }, { - id: 'custom-malformed', - name: 'Custom Malformed', + id: 'custom-without-inputs', + name: 'Custom Without Inputs', color: '#654321', pineCode: 'trigger-capable', - inputMeta: { - Broken: { title: '' }, - }, + inputMeta: undefined, }, ]) - mockFrom.mockReturnValue({ where: mockWhere }) - mockSelect.mockReturnValue({ from: mockFrom }) }) const getOptions = async (search: string) => { @@ -120,14 +94,14 @@ describe('indicator options route', () => { return GET(new NextRequest(`http://localhost/api/indicators/options${search}`)) } - it('returns monitor-surface trigger-capable options with normalized input metadata', async () => { + it('returns monitor-surface trigger-capable options with derived input metadata', async () => { const response = await getOptions('?workspaceId=workspace-1&surface=monitor') const payload = await response.json() expect(response.status).toBe(200) expect(payload.data.map((entry: any) => entry.id).sort()).toEqual([ - 'custom-malformed', 'custom-trigger', + 'custom-without-inputs', 'default-trigger', ]) @@ -148,9 +122,11 @@ describe('indicator options route', () => { }) ) - const malformedOption = payload.data.find((entry: any) => entry.id === 'custom-malformed') - expect(malformedOption.inputTitles).toEqual([]) - expect(malformedOption.inputMeta).toBeUndefined() + const optionWithoutInputs = payload.data.find( + (entry: any) => entry.id === 'custom-without-inputs' + ) + expect(optionWithoutInputs.inputTitles).toEqual([]) + expect(optionWithoutInputs.inputMeta).toBeUndefined() }) it('keeps copilot surface broader than monitor surface', async () => { @@ -159,9 +135,9 @@ describe('indicator options route', () => { expect(response.status).toBe(200) expect(payload.data.map((entry: any) => entry.id).sort()).toEqual([ - 'custom-malformed', 'custom-study', 'custom-trigger', + 'custom-without-inputs', 'default-study', 'default-trigger', ]) diff --git a/apps/tradinggoose/app/api/indicators/options/route.ts b/apps/tradinggoose/app/api/indicators/options/route.ts index 56263cfd1..fb8003452 100644 --- a/apps/tradinggoose/app/api/indicators/options/route.ts +++ b/apps/tradinggoose/app/api/indicators/options/route.ts @@ -1,15 +1,12 @@ -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,23 +83,12 @@ 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)) .map((row) => { - const inputMeta = normalizeInputMetaMap(row.inputMeta) + const inputMeta = row.inputMeta ?? undefined const inputTitles = Object.keys(inputMeta ?? {}) return { @@ -111,10 +97,11 @@ export async function GET(request: NextRequest) { source: 'custom', color: row.color?.trim() || '#3972F6', editable: true, - callableInFunctionBlock: false, + callableInFunctionBlock: true, inputTitles, ...(inputMeta && inputTitles.length > 0 ? { inputMeta } : {}), entityId: row.id, + runtimeId: row.id, } }) @@ -124,6 +111,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/jobs/[jobId]/route.test.ts b/apps/tradinggoose/app/api/jobs/[jobId]/route.test.ts index 56bcd02c3..e624db872 100644 --- a/apps/tradinggoose/app/api/jobs/[jobId]/route.test.ts +++ b/apps/tradinggoose/app/api/jobs/[jobId]/route.test.ts @@ -185,7 +185,7 @@ describe('GET /api/jobs/[jobId]', () => { }) }) - it('returns public workflow output for completed workflow jobs', async () => { + it('returns workflow execution output for completed workflow jobs', async () => { mockCompletedWorkflowJob({ source: 'workflow_execute_api' }) const response = await GET(new Request('http://localhost/api/jobs/job-1') as any, { 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..e5e9707e4 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,10 @@ 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' +import { refreshEntityListSession } from '@/lib/yjs/server/snapshot-bridge' const logger = createLogger('McpServerRefreshAPI') @@ -31,7 +33,7 @@ export const POST = withMcpAuth('read')( ) const [server] = await db - .select() + .select({ lastConnected: mcpServers.lastConnected }) .from(mcpServers) .where( and( @@ -55,40 +57,55 @@ export const POST = withMcpAuth('read')( let lastError: string | null = null try { - const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId) + const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId, false) connectionStatus = 'connected' toolCount = tools.length logger.info( `[${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(), + updatedAt: now, }) - .where(eq(mcpServers.id, serverId)) - .returning() + .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) + await refreshEntityListSession('mcp_server', 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 deleted file mode 100644 index 1ebafe741..000000000 --- a/apps/tradinggoose/app/api/mcp/servers/[id]/route.ts +++ /dev/null @@ -1,112 +0,0 @@ -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 { 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' - -const logger = createLogger('McpServerAPI') - -export const dynamic = 'force-dynamic' - -/** - * PATCH - Update an MCP server in the workspace (requires write or admin permission) - */ -export const PATCH = withMcpAuth('write')( - async ( - request: NextRequest, - { userId, workspaceId, requestId }, - { params }: { params: { id: string } } - ) => { - const serverId = params.id - - try { - const rawBody = getParsedBody(request) || (await request.json()) - - const parseResult = UpdateMcpServerSchema.safeParse(rawBody) - if (!parseResult.success) { - return createMcpErrorResponse( - new Error(`Invalid request body: ${parseResult.error.message}`), - 'Invalid request body', - 400 - ) - } - - const body = parseResult.data - - logger.info(`[${requestId}] Updating MCP server: ${serverId} in workspace: ${workspaceId}`, { - userId, - updates: Object.keys(body).filter((k) => k !== 'workspaceId'), - }) - - // 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) { - return createMcpErrorResponse( - new Error('Server not found or access denied'), - 'Server not found', - 404 - ) - } - - await applySavedEntityState( - 'mcp_server', - updatedServer.id, - savedEntityRowToFields('mcp_server', updatedServer) - ) - - // Clear MCP service cache after update - mcpService.clearCache(workspaceId) - - logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`) - return createMcpSuccessResponse({ server: updatedServer }) - } catch (error) { - logger.error(`[${requestId}] Error updating MCP server:`, error) - return createMcpErrorResponse( - error instanceof Error ? error : new Error('Failed to update MCP server'), - 'Failed to update MCP server', - 500 - ) - } - } -) diff --git a/apps/tradinggoose/app/api/mcp/servers/route.ts b/apps/tradinggoose/app/api/mcp/servers/route.ts index 3bfb06406..d4e7ecc0c 100644 --- a/apps/tradinggoose/app/api/mcp/servers/route.ts +++ b/apps/tradinggoose/app/api/mcp/servers/route.ts @@ -1,32 +1,23 @@ import { db } from '@tradinggoose/db' import { mcpServers } from '@tradinggoose/db/schema' -import { and, eq, isNull } from 'drizzle-orm' +import { and, asc, 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 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 { - 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, + refreshEntityListSession, +} 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 */ @@ -36,10 +27,33 @@ export const GET = withMcpAuth('read')( logger.info(`[${requestId}] Listing MCP servers for workspace ${workspaceId}`) const rows = await db - .select() + .select({ + id: mcpServers.id, + name: mcpServers.name, + enabled: mcpServers.enabled, + 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), isNull(mcpServers.deletedAt))) - const servers = await applySavedEntityYjsStateToRows('mcp_server', rows) + .orderBy(asc(mcpServers.name), asc(mcpServers.id)) + + const servers = rows.map((server) => ({ + id: server.id, + name: server.name, + enabled: server.enabled !== false, + workspaceId, + updatedAt: server.updatedAt?.toISOString(), + connectionStatus: server.connectionStatus, + lastError: server.lastError, + toolCount: server.toolCount, + lastConnected: server.lastConnected?.toISOString(), + lastToolsRefresh: server.lastToolsRefresh?.toISOString(), + })) logger.info( `[${requestId}] Listed ${servers.length} MCP servers for workspace ${workspaceId}` @@ -81,67 +95,32 @@ 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) + } logger.error(`[${requestId}] Error registering MCP server:`, error) return createMcpErrorResponse( error instanceof Error ? error : new Error('Failed to register MCP server'), @@ -171,13 +150,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 +163,23 @@ 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 refreshEntityListSession('mcp_server', workspaceId) + await Promise.allSettled([deleteYjsSessionInSocketServer(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..e2a21b5fb 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,10 @@ 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(), -}) - -/** - * 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 CreateMcpServerSchema = McpServerBaseSchema.refine( + (server) => server.enabled === false || !!server.url?.trim(), + { + message: 'URL is required when an MCP server is enabled', + path: ['url'], + } +) diff --git a/apps/tradinggoose/app/api/mcp/tools/discover/route.ts b/apps/tradinggoose/app/api/mcp/tools/discover/route.ts index 8ae3dfb59..2ad8318da 100644 --- a/apps/tradinggoose/app/api/mcp/tools/discover/route.ts +++ b/apps/tradinggoose/app/api/mcp/tools/discover/route.ts @@ -17,19 +17,23 @@ export const GET = withMcpAuth('read')( try { const { searchParams } = new URL(request.url) const serverId = searchParams.get('serverId') - const forceRefresh = searchParams.get('refresh') === 'true' + const isDeployedContext = searchParams.get('isDeployedContext') !== 'false' logger.info(`[${requestId}] Discovering MCP tools for user ${userId}`, { serverId, workspaceId, - forceRefresh, }) let tools if (serverId) { - tools = await mcpService.discoverServerTools(userId, serverId, workspaceId) + tools = await mcpService.discoverServerTools( + userId, + serverId, + workspaceId, + isDeployedContext + ) } else { - tools = await mcpService.discoverTools(userId, workspaceId, forceRefresh) + tools = await mcpService.discoverTools(userId, workspaceId, isDeployedContext) } const byServer: Record = {} @@ -63,6 +67,7 @@ export const POST = withMcpAuth('read')( try { const body = getParsedBody(request) || (await request.json()) const { serverIds } = body + const isDeployedContext = body.isDeployedContext !== false if (!Array.isArray(serverIds)) { return createMcpErrorResponse( @@ -79,7 +84,12 @@ export const POST = withMcpAuth('read')( const results = await Promise.allSettled( serverIds.map(async (serverId: string) => { - const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId) + const tools = await mcpService.discoverServerTools( + userId, + serverId, + workspaceId, + isDeployedContext + ) return { serverId, toolCount: tools.length } }) ) diff --git a/apps/tradinggoose/app/api/mcp/tools/execute/route.ts b/apps/tradinggoose/app/api/mcp/tools/execute/route.ts index 63845865f..eb18f5b6f 100644 --- a/apps/tradinggoose/app/api/mcp/tools/execute/route.ts +++ b/apps/tradinggoose/app/api/mcp/tools/execute/route.ts @@ -58,6 +58,7 @@ export const POST = withMcpAuth('read')( }) const { serverId, toolName, arguments: args } = body + const isDeployedContext = body.isDeployedContext !== false const serverIdValidation = validateStringParam(serverId, 'serverId') if (!serverIdValidation.isValid) { @@ -77,7 +78,12 @@ export const POST = withMcpAuth('read')( let tool = null try { - const tools = await mcpService.discoverServerTools(userId, serverId, workspaceId) + const tools = await mcpService.discoverServerTools( + userId, + serverId, + workspaceId, + isDeployedContext + ) tool = tools.find((t) => t.name === toolName) if (!tool) { @@ -176,7 +182,7 @@ export const POST = withMcpAuth('read')( } const result = await Promise.race([ - mcpService.executeTool(userId, serverId, toolCall, workspaceId), + mcpService.executeTool(userId, serverId, toolCall, workspaceId, isDeployedContext), new Promise((_, reject) => setTimeout( () => reject(new Error('Tool execution timeout')), 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..6752f33d1 100644 --- a/apps/tradinggoose/app/api/monitors/shared.ts +++ b/apps/tradinggoose/app/api/monitors/shared.ts @@ -7,7 +7,7 @@ import { } from '@tradinggoose/db/schema' import { and, desc, eq, inArray, sql } from 'drizzle-orm' import { DEFAULT_INDICATOR_RUNTIME_MAP } from '@/lib/indicators/default/runtime' -import { normalizeInputMetaMap } from '@/lib/indicators/input-meta' +import { inferInputMetaFromPineCode } from '@/lib/indicators/input-meta' import { type IndicatorMonitorProviderConfig, toPublicIndicatorMonitorProviderConfig, @@ -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) { @@ -301,19 +299,18 @@ export const loadIndicatorInputMetadata = async ( .select({ id: pineIndicators.id, workspaceId: pineIndicators.workspaceId, - inputMeta: pineIndicators.inputMeta, + pineCode: pineIndicators.pineCode, }) .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) { throw new Error(`Indicator ${indicatorId} not found.`) } - const inputMeta = normalizeInputMetaMap(row.inputMeta) + const inputMeta = inferInputMetaFromPineCode(row.pineCode) return { id: row.id, ...(inputMeta && Object.keys(inputMeta).length > 0 ? { inputMeta } : {}), 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/ai/handler.ts b/apps/tradinggoose/app/api/providers/ai/handler.ts index 82e649ff8..13a3d8ea8 100644 --- a/apps/tradinggoose/app/api/providers/ai/handler.ts +++ b/apps/tradinggoose/app/api/providers/ai/handler.ts @@ -42,6 +42,7 @@ export interface ProviderRouteBody { reasoningEffort?: string verbosity?: string thinkingLevel?: string + isDeployedContext?: boolean } interface HandleAIProviderParams { @@ -88,6 +89,7 @@ export async function handleAIProviderRequest({ reasoningEffort, verbosity, thinkingLevel, + isDeployedContext, } = body const providerConfig = getProvider(providerId) @@ -182,6 +184,7 @@ export async function handleAIProviderRequest({ reasoningEffort, verbosity, thinkingLevel, + isDeployedContext, }) const executionTime = Date.now() - startTime 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/schedules/execute/route.test.ts b/apps/tradinggoose/app/api/schedules/execute/route.test.ts index ce360a70c..bb942537b 100644 --- a/apps/tradinggoose/app/api/schedules/execute/route.test.ts +++ b/apps/tradinggoose/app/api/schedules/execute/route.test.ts @@ -89,7 +89,7 @@ describe('Scheduled Workflow Execution API Route', () => { { id: 'schedule-1', workflowId: 'workflow-1', - blockId: null, + blockId: 'schedule-trigger-1', cronExpression: null, lastRanAt: null, failedCount: 0, @@ -151,7 +151,7 @@ describe('Scheduled Workflow Execution API Route', () => { expect(data.error).toContain('Trigger.dev is required for scheduled executions') }) - it('should queue schedules through pending execution when enabled', async () => { + it('should queue configured schedules and remove orphan schedule rows', async () => { vi.doMock('@/lib/auth/internal', () => ({ verifyCronAuth: vi.fn().mockReturnValue(null), })) @@ -189,18 +189,29 @@ describe('Scheduled Workflow Execution API Route', () => { isPendingExecutionLimitError: vi.fn(() => false), })) + let deletedScheduleWhere: Record | undefined vi.doMock('@tradinggoose/db', () => { const scheduleRows = [ { id: 'schedule-1', workflowId: 'workflow-1', - blockId: null, + blockId: 'schedule-trigger-1', cronExpression: null, lastRanAt: null, failedCount: 0, timezone: 'UTC', nextRunAt: new Date('2024-01-01T00:00:00.000Z'), }, + { + id: 'schedule-missing-trigger', + workflowId: 'workflow-2', + blockId: null, + cronExpression: null, + lastRanAt: null, + failedCount: 1, + timezone: 'UTC', + nextRunAt: new Date('2024-01-01T00:00:00.000Z'), + }, ] const workflowRows = [ @@ -231,6 +242,12 @@ describe('Scheduled Workflow Execution API Route', () => { }), } }), + delete: vi.fn().mockImplementation(() => ({ + where: vi.fn().mockImplementation((condition) => { + deletedScheduleWhere = condition + return Promise.resolve([]) + }), + })), } return { @@ -247,6 +264,12 @@ describe('Scheduled Workflow Execution API Route', () => { expect(response.status).toBe(200) const data = await response.json() expect(data).toHaveProperty('executedCount', 1) + expect(deletedScheduleWhere).toEqual( + expect.objectContaining({ + type: 'eq', + value: 'schedule-missing-trigger', + }) + ) expect(enqueuePendingExecutionMock).toHaveBeenCalledWith( expect.objectContaining({ executionType: 'schedule', @@ -349,7 +372,7 @@ describe('Scheduled Workflow Execution API Route', () => { { id: 'schedule-1', workflowId: 'workflow-1', - blockId: null, + blockId: 'schedule-trigger-1', cronExpression: null, lastRanAt: null, failedCount: 0, @@ -359,7 +382,7 @@ describe('Scheduled Workflow Execution API Route', () => { { id: 'schedule-2', workflowId: 'workflow-2', - blockId: null, + blockId: 'schedule-trigger-2', cronExpression: null, lastRanAt: null, failedCount: 0, diff --git a/apps/tradinggoose/app/api/schedules/execute/route.ts b/apps/tradinggoose/app/api/schedules/execute/route.ts index c99e5fd0b..de99b4c1e 100644 --- a/apps/tradinggoose/app/api/schedules/execute/route.ts +++ b/apps/tradinggoose/app/api/schedules/execute/route.ts @@ -1,8 +1,8 @@ import { db, workflow, workflowSchedule } from '@tradinggoose/db' import { and, eq, lte, not } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { verifyCronAuth } from '@/lib/auth/internal' import { getApiKeyOwnerUserId } from '@/lib/api-key/service' +import { verifyCronAuth } from '@/lib/auth/internal' import { enqueuePendingExecution, isPendingExecutionLimitError, @@ -40,94 +40,95 @@ export async function GET(request: NextRequest) { const queuedSchedules = await Promise.all( dueSchedules.map(async (schedule) => { try { + if (typeof schedule.blockId !== 'string' || schedule.blockId.length === 0) { + logger.warn( + `[${requestId}] Removing schedule ${schedule.id}: missing schedule trigger block.` + ) + await db.delete(workflowSchedule).where(eq(workflowSchedule.id, schedule.id)) + return null + } + const [workflowRecord] = await db .select({ - workspaceId: workflow.workspaceId, - pinnedApiKeyId: workflow.pinnedApiKeyId, + workspaceId: workflow.workspaceId, + pinnedApiKeyId: workflow.pinnedApiKeyId, + }) + .from(workflow) + .where(eq(workflow.id, schedule.workflowId)) + .limit(1) + + if (!workflowRecord) { + logger.warn( + `[${requestId}] Workflow ${schedule.workflowId} not found for schedule ${schedule.id}` + ) + return null + } + + const actorUserId = await getApiKeyOwnerUserId(workflowRecord.pinnedApiKeyId) + + if (!actorUserId) { + logger.warn( + `[${requestId}] Skipping schedule ${schedule.id}: pinned API key required to attribute usage.` + ) + return null + } + + const pendingExecutionId = `schedule_execution:${schedule.id}:${schedule.nextRunAt?.toISOString() ?? now.toISOString()}` + const payload = { + executionId: pendingExecutionId, + scheduleId: schedule.id, + workflowId: schedule.workflowId, + blockId: schedule.blockId, + cronExpression: schedule.cronExpression || undefined, + lastRanAt: schedule.lastRanAt?.toISOString(), + failedCount: schedule.failedCount || 0, + timezone: schedule.timezone, + now: now.toISOString(), + } + + const handle = await enqueuePendingExecution({ + executionType: 'schedule', + pendingExecutionId, + workflowId: schedule.workflowId, + workspaceId: workflowRecord.workspaceId, + userId: actorUserId, + source: 'schedule', + orderingKey: `schedule:${schedule.id}`, + requestId, + payload, }) - .from(workflow) - .where(eq(workflow.id, schedule.workflowId)) - .limit(1) - if (!workflowRecord) { - logger.warn( - `[${requestId}] Workflow ${schedule.workflowId} not found for schedule ${schedule.id}`, - ) - return null - } - - const actorUserId = await getApiKeyOwnerUserId( - workflowRecord.pinnedApiKeyId, - ) + if (!handle.inserted) return null - if (!actorUserId) { - logger.warn( - `[${requestId}] Skipping schedule ${schedule.id}: pinned API key required to attribute usage.`, + logger.info( + `[${requestId}] Queued schedule execution ${handle.pendingExecutionId} for workflow ${schedule.workflowId}` ) - return null - } - - const pendingExecutionId = `schedule_execution:${schedule.id}:${schedule.nextRunAt?.toISOString() ?? now.toISOString()}` - const payload = { - executionId: pendingExecutionId, - scheduleId: schedule.id, - workflowId: schedule.workflowId, - blockId: schedule.blockId || undefined, - cronExpression: schedule.cronExpression || undefined, - lastRanAt: schedule.lastRanAt?.toISOString(), - failedCount: schedule.failedCount || 0, - timezone: schedule.timezone, - now: now.toISOString(), - } - - const handle = await enqueuePendingExecution({ - executionType: 'schedule', - pendingExecutionId, - workflowId: schedule.workflowId, - workspaceId: workflowRecord.workspaceId, - userId: actorUserId, - source: 'schedule', - orderingKey: `schedule:${schedule.id}`, - requestId, - payload, - }) - - if (!handle.inserted) return null - - logger.info( - `[${requestId}] Queued schedule execution ${handle.pendingExecutionId} for workflow ${schedule.workflowId}`, - ) - return handle - } catch (error) { - if (isPendingExecutionLimitError(error)) { - logger.warn( - `[${requestId}] Pending backlog full for schedule ${schedule.id}`, - { + return handle + } catch (error) { + if (isPendingExecutionLimitError(error)) { + logger.warn(`[${requestId}] Pending backlog full for schedule ${schedule.id}`, { workflowId: schedule.workflowId, pendingCount: error.details.pendingCount, maxPendingCount: error.details.maxPendingCount, - }, + }) + return null + } + + if (error instanceof TriggerExecutionUnavailableError) { + throw error + } + + logger.error( + `[${requestId}] Failed to trigger schedule execution for workflow ${schedule.workflowId}`, + error ) return null } - - if (error instanceof TriggerExecutionUnavailableError) { - throw error - } - - logger.error( - `[${requestId}] Failed to trigger schedule execution for workflow ${schedule.workflowId}`, - error - ) - return null - } }) ) const queuedCount = queuedSchedules.filter((result) => result !== null).length - logger.info( - `[${requestId}] Queued ${queuedCount} schedule executions to pending execution`, - ) + logger.info(`[${requestId}] Queued ${queuedCount} schedule executions to pending execution`) return NextResponse.json({ message: 'Scheduled workflow executions processed', diff --git a/apps/tradinggoose/app/api/schedules/route.ts b/apps/tradinggoose/app/api/schedules/route.ts index f12fde77e..92da584ce 100644 --- a/apps/tradinggoose/app/api/schedules/route.ts +++ b/apps/tradinggoose/app/api/schedules/route.ts @@ -5,7 +5,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' -import { resolveTimezoneState } from '@/lib/timezone/timezone-resolver' import { getUserEntityPermissions } from '@/lib/permissions/utils' import { type BlockState, @@ -15,13 +14,14 @@ import { getSubBlockValue, validateCronExpression, } from '@/lib/schedules/utils' +import { resolveTimezoneState } from '@/lib/timezone/timezone-resolver' import { generateRequestId } from '@/lib/utils' const logger = createLogger('ScheduledAPI') const ScheduleRequestSchema = z.object({ workflowId: z.string(), - blockId: z.string().optional(), + blockId: z.string().min(1), state: z.object({ blocks: z.record(z.any()), edges: z.array(z.any()), @@ -212,68 +212,37 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Not authorized to modify this workflow' }, { status: 403 }) } - // Find the target block - prioritize the specific blockId if provided - let targetBlock: BlockState | undefined - if (blockId) { - targetBlock = Object.values(state.blocks).find((block: any) => block.id === blockId) as - | BlockState - | undefined - } else { - targetBlock = Object.values(state.blocks).find( - (block: any) => block.type === 'schedule' - ) as BlockState | undefined - } + const targetBlock = Object.values(state.blocks).find((block: any) => block.id === blockId) as + | BlockState + | undefined if (!targetBlock) { - logger.warn(`[${requestId}] No schedule block found in workflow ${workflowId}`) - return NextResponse.json( - { error: 'No schedule block found in workflow' }, - { status: 400 } - ) + logger.warn(`[${requestId}] Schedule block ${blockId} not found in workflow ${workflowId}`) + return NextResponse.json({ error: 'Schedule block not found in workflow' }, { status: 400 }) } const scheduleType = getSubBlockValue(targetBlock, 'scheduleType') - const isScheduleBlock = targetBlock.type === 'schedule' + if (targetBlock.type !== 'schedule') { + return NextResponse.json({ error: 'Schedule block is required' }, { status: 400 }) + } const scheduleValues = getScheduleTimeValues(targetBlock) const hasValidConfig = hasValidScheduleConfig(scheduleType, scheduleValues, targetBlock) - // Debug logging to understand why validation fails - logger.info(`[${requestId}] Schedule validation debug:`, { - workflowId, - blockId, - blockType: targetBlock.type, - scheduleType, - hasValidConfig, - scheduleValues: { - minutesInterval: scheduleValues.minutesInterval, - dailyTime: scheduleValues.dailyTime, - cronExpression: scheduleValues.cronExpression, - }, - }) - if (!hasValidConfig) { logger.info( `[${requestId}] Removing schedule for workflow ${workflowId} - no valid configuration found` ) - // Build delete conditions - const deleteConditions = [eq(workflowSchedule.workflowId, workflowId)] - if (blockId) { - deleteConditions.push(eq(workflowSchedule.blockId, blockId)) - } - await db .delete(workflowSchedule) - .where(deleteConditions.length > 1 ? and(...deleteConditions) : deleteConditions[0]) + .where( + and(eq(workflowSchedule.workflowId, workflowId), eq(workflowSchedule.blockId, blockId)) + ) return NextResponse.json({ message: 'Schedule removed' }) } - if (isScheduleBlock) { - logger.info(`[${requestId}] Processing schedule trigger block for workflow ${workflowId}`) - } - logger.debug(`[${requestId}] Schedule type for workflow ${workflowId}: ${scheduleType}`) let cronExpression: string | null = null @@ -313,7 +282,12 @@ export async function POST(req: NextRequest) { } } - nextRunAt = calculateNextRunTime(defaultScheduleType, scheduleValues, undefined, utcOffsetMinutes) + nextRunAt = calculateNextRunTime( + defaultScheduleType, + scheduleValues, + undefined, + utcOffsetMinutes + ) logger.debug( `[${requestId}] Generated cron: ${cronExpression}, next run at: ${nextRunAt.toISOString()}` 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..9d9131e06 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) }) @@ -76,6 +79,15 @@ describe('Skills API Routes', () => { expect(body.error).toBe('workspaceId is required') }) + it('GET should list live workspace skills', async () => { + const req = new NextRequest('http://localhost:3000/api/skills?workspaceId=ws-1') + const { GET } = await import('@/app/api/skills/route') + const res = await GET(req) + + expect(res.status).toBe(200) + expect(mockListSkills).toHaveBeenCalledWith({ workspaceId: 'ws-1' }) + }) + it('POST should require workspaceId in body', async () => { const req = new NextRequest('http://localhost:3000/api/skills', { method: 'POST', @@ -99,7 +111,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]/route.ts b/apps/tradinggoose/app/api/templates/[id]/route.ts deleted file mode 100644 index 9d73df795..000000000 --- a/apps/tradinggoose/app/api/templates/[id]/route.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { db } from '@tradinggoose/db' -import { templates, workflow } from '@tradinggoose/db/schema' -import { eq, sql } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { z } from 'zod' -import { getSession } from '@/lib/auth' -import { createLogger } from '@/lib/logs/console/logger' -import { hasAdminPermission } from '@/lib/permissions/utils' -import { generateRequestId } from '@/lib/utils' - -const logger = createLogger('TemplateByIdAPI') - -export const revalidate = 0 - -// GET /api/templates/[id] - Retrieve a single template by ID -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized template access attempt for ID: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - logger.debug(`[${requestId}] Fetching template: ${id}`) - - // Fetch the template by ID - const result = await db.select().from(templates).where(eq(templates.id, id)).limit(1) - - if (result.length === 0) { - logger.warn(`[${requestId}] Template not found: ${id}`) - return NextResponse.json({ error: 'Template not found' }, { status: 404 }) - } - - const template = result[0] - - // Increment the view count - try { - await db - .update(templates) - .set({ - views: sql`${templates.views} + 1`, - updatedAt: new Date(), - }) - .where(eq(templates.id, id)) - - logger.debug(`[${requestId}] Incremented view count for template: ${id}`) - } catch (viewError) { - // Log the error but don't fail the request - logger.warn(`[${requestId}] Failed to increment view count for template: ${id}`, viewError) - } - - logger.info(`[${requestId}] Successfully retrieved template: ${id}`) - - return NextResponse.json({ - data: { - ...template, - views: template.views + 1, // Return the incremented view count - }, - }) - } catch (error: any) { - logger.error(`[${requestId}] Error fetching template: ${id}`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -const updateTemplateSchema = z.object({ - name: z.string().min(1).max(100), - description: z.string().min(1).max(500), - author: z.string().min(1).max(100), - category: z.string().min(1), - icon: z.string().min(1), - color: z.string().regex(/^#[0-9A-F]{6}$/i), - state: z.any().optional(), // Workflow state -}) - -// PUT /api/templates/[id] - Update a template -export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized template update attempt for ID: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const body = await request.json() - const validationResult = updateTemplateSchema.safeParse(body) - - if (!validationResult.success) { - logger.warn(`[${requestId}] Invalid template data for update: ${id}`, validationResult.error) - return NextResponse.json( - { error: 'Invalid template data', details: validationResult.error.errors }, - { status: 400 } - ) - } - - const { name, description, author, category, icon, color, state } = validationResult.data - - // Check if template exists - const existingTemplate = await db.select().from(templates).where(eq(templates.id, id)).limit(1) - - if (existingTemplate.length === 0) { - logger.warn(`[${requestId}] Template not found for update: ${id}`) - return NextResponse.json({ error: 'Template not found' }, { status: 404 }) - } - - // Permission: template owner OR admin of the workflow's workspace (if any) - let canUpdate = existingTemplate[0].userId === session.user.id - - if (!canUpdate && existingTemplate[0].workflowId) { - const wfRows = await db - .select({ workspaceId: workflow.workspaceId }) - .from(workflow) - .where(eq(workflow.id, existingTemplate[0].workflowId)) - .limit(1) - - const workspaceId = wfRows[0]?.workspaceId as string | null | undefined - if (workspaceId) { - const hasAdmin = await hasAdminPermission(session.user.id, workspaceId) - if (hasAdmin) canUpdate = true - } - } - - if (!canUpdate) { - logger.warn(`[${requestId}] User denied permission to update template ${id}`) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } - - // Update the template - const updatedTemplate = await db - .update(templates) - .set({ - name, - description, - author, - category, - icon, - color, - ...(state && { state }), - updatedAt: new Date(), - }) - .where(eq(templates.id, id)) - .returning() - - logger.info(`[${requestId}] Successfully updated template: ${id}`) - - return NextResponse.json({ - data: updatedTemplate[0], - message: 'Template updated successfully', - }) - } catch (error: any) { - logger.error(`[${requestId}] Error updating template: ${id}`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -// DELETE /api/templates/[id] - Delete a template -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized template delete attempt for ID: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Fetch template - const existing = await db.select().from(templates).where(eq(templates.id, id)).limit(1) - if (existing.length === 0) { - logger.warn(`[${requestId}] Template not found for delete: ${id}`) - return NextResponse.json({ error: 'Template not found' }, { status: 404 }) - } - - const template = existing[0] - - // Permission: owner or admin of the workflow's workspace (if any) - let canDelete = template.userId === session.user.id - - if (!canDelete && template.workflowId) { - // Look up workflow to get workspaceId - const wfRows = await db - .select({ workspaceId: workflow.workspaceId }) - .from(workflow) - .where(eq(workflow.id, template.workflowId)) - .limit(1) - - const workspaceId = wfRows[0]?.workspaceId as string | null | undefined - if (workspaceId) { - const hasAdmin = await hasAdminPermission(session.user.id, workspaceId) - if (hasAdmin) canDelete = true - } - } - - if (!canDelete) { - logger.warn(`[${requestId}] User denied permission to delete template ${id}`) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } - - await db.delete(templates).where(eq(templates.id, id)) - - logger.info(`[${requestId}] Deleted template: ${id}`) - return NextResponse.json({ success: true }) - } catch (error: any) { - logger.error(`[${requestId}] Error deleting template: ${id}`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} diff --git a/apps/tradinggoose/app/api/templates/[id]/star/route.ts b/apps/tradinggoose/app/api/templates/[id]/star/route.ts deleted file mode 100644 index 70c84abe4..000000000 --- a/apps/tradinggoose/app/api/templates/[id]/star/route.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { db } from '@tradinggoose/db' -import { templateStars, templates } from '@tradinggoose/db/schema' -import { and, eq, sql } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { v4 as uuidv4 } from 'uuid' -import { getSession } from '@/lib/auth' -import { createLogger } from '@/lib/logs/console/logger' -import { generateRequestId } from '@/lib/utils' - -const logger = createLogger('TemplateStarAPI') - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -// GET /api/templates/[id]/star - Check if user has starred this template -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized star check attempt for template: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - logger.debug( - `[${requestId}] Checking star status for template: ${id}, user: ${session.user.id}` - ) - - // Check if the user has starred this template - const starRecord = await db - .select({ id: templateStars.id }) - .from(templateStars) - .where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id))) - .limit(1) - - const isStarred = starRecord.length > 0 - - logger.info(`[${requestId}] Star status checked: ${isStarred} for template: ${id}`) - - return NextResponse.json({ data: { isStarred } }) - } catch (error: any) { - logger.error(`[${requestId}] Error checking star status for template: ${id}`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -// POST /api/templates/[id]/star - Add a star to the template -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized star attempt for template: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - logger.debug(`[${requestId}] Adding star for template: ${id}, user: ${session.user.id}`) - - // Verify the template exists - const templateExists = await db - .select({ id: templates.id }) - .from(templates) - .where(eq(templates.id, id)) - .limit(1) - - if (templateExists.length === 0) { - logger.warn(`[${requestId}] Template not found: ${id}`) - return NextResponse.json({ error: 'Template not found' }, { status: 404 }) - } - - // Check if user has already starred this template - const existingStar = await db - .select({ id: templateStars.id }) - .from(templateStars) - .where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id))) - .limit(1) - - if (existingStar.length > 0) { - logger.info(`[${requestId}] Template already starred: ${id}`) - return NextResponse.json({ message: 'Template already starred' }, { status: 200 }) - } - - // Use a transaction to ensure consistency - await db.transaction(async (tx) => { - // Add the star record - await tx.insert(templateStars).values({ - id: uuidv4(), - userId: session.user.id, - templateId: id, - starredAt: new Date(), - createdAt: new Date(), - }) - - // Increment the star count - await tx - .update(templates) - .set({ - stars: sql`${templates.stars} + 1`, - updatedAt: new Date(), - }) - .where(eq(templates.id, id)) - }) - - logger.info(`[${requestId}] Successfully starred template: ${id}`) - return NextResponse.json({ message: 'Template starred successfully' }, { status: 201 }) - } catch (error: any) { - // Handle unique constraint violations gracefully - if (error.code === '23505') { - logger.info(`[${requestId}] Duplicate star attempt for template: ${id}`) - return NextResponse.json({ message: 'Template already starred' }, { status: 200 }) - } - - logger.error(`[${requestId}] Error starring template: ${id}`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -// DELETE /api/templates/[id]/star - Remove a star from the template -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized unstar attempt for template: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - logger.debug(`[${requestId}] Removing star for template: ${id}, user: ${session.user.id}`) - - // Check if the star exists - const existingStar = await db - .select({ id: templateStars.id }) - .from(templateStars) - .where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id))) - .limit(1) - - if (existingStar.length === 0) { - logger.info(`[${requestId}] No star found to remove for template: ${id}`) - return NextResponse.json({ message: 'Template not starred' }, { status: 200 }) - } - - // Use a transaction to ensure consistency - await db.transaction(async (tx) => { - // Remove the star record - await tx - .delete(templateStars) - .where(and(eq(templateStars.templateId, id), eq(templateStars.userId, session.user.id))) - - // Decrement the star count (prevent negative values) - await tx - .update(templates) - .set({ - stars: sql`GREATEST(${templates.stars} - 1, 0)`, - updatedAt: new Date(), - }) - .where(eq(templates.id, id)) - }) - - logger.info(`[${requestId}] Successfully unstarred template: ${id}`) - return NextResponse.json({ message: 'Template unstarred successfully' }, { status: 200 }) - } catch (error: any) { - logger.error(`[${requestId}] Error unstarring template: ${id}`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} diff --git a/apps/tradinggoose/app/api/templates/[id]/use/route.ts b/apps/tradinggoose/app/api/templates/[id]/use/route.ts deleted file mode 100644 index e8474a355..000000000 --- a/apps/tradinggoose/app/api/templates/[id]/use/route.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { db } from '@tradinggoose/db' -import { templates, workflow } from '@tradinggoose/db/schema' -import { eq, sql } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -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 { normalizeVariables } from '@/lib/workflows/variable-utils' -import { regenerateWorkflowStateIds, remapVariableIds } from '@/lib/workflows/db-helpers' - -const logger = createLogger('TemplateUseAPI') - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -// POST /api/templates/[id]/use - Use a template (increment views and create workflow) -export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - const { id } = await params - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized use attempt for template: ${id}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Get workspace ID from request body - const body = await request.json() - const { workspaceId } = body - - if (!workspaceId) { - logger.warn(`[${requestId}] Missing workspaceId in request body`) - return NextResponse.json({ error: 'Workspace ID is required' }, { status: 400 }) - } - - logger.debug( - `[${requestId}] Using template: ${id}, user: ${session.user.id}, workspace: ${workspaceId}` - ) - - // Get the template with its data - const template = await db - .select({ - id: templates.id, - name: templates.name, - description: templates.description, - state: templates.state, - color: templates.color, - }) - .from(templates) - .where(eq(templates.id, id)) - .limit(1) - - if (template.length === 0) { - logger.warn(`[${requestId}] Template not found: ${id}`) - return NextResponse.json({ error: 'Template not found' }, { status: 404 }) - } - - const templateData = template[0] - - // Create a new workflow ID - const newWorkflowId = uuidv4() - const now = new Date() - - const templateState = - templateData.state && typeof templateData.state === 'object' ? (templateData.state as any) : null - - const templateVariables = normalizeVariables(templateState?.variables) - const remappedVariables = remapVariableIds(templateVariables, newWorkflowId) - - await db.insert(workflow).values({ - id: newWorkflowId, - workspaceId: workspaceId, - name: `${templateData.name} (copy)`, - 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 } - ) - } - } - - await db - .update(templates) - .set({ - views: sql`${templates.views} + 1`, - updatedAt: new Date(), - }) - .where(eq(templates.id, id)) - - logger.info( - `[${requestId}] Successfully used template: ${id}, created workflow: ${newWorkflowId}` - ) - - // Track template usage - try { - const { trackPlatformEvent } = await import('@/lib/telemetry/tracer') - const templateState = templateData.state as any - trackPlatformEvent('platform.template.used', { - 'template.id': id, - 'template.name': templateData.name, - 'workflow.created_id': newWorkflowId, - 'workflow.blocks_count': templateState?.blocks - ? Object.keys(templateState.blocks).length - : 0, - 'workspace.id': workspaceId, - }) - } catch (_e) { - // Silently fail - } - - // Verify the workflow was actually created - const verifyWorkflow = await db - .select({ id: workflow.id }) - .from(workflow) - .where(eq(workflow.id, newWorkflowId)) - .limit(1) - - if (verifyWorkflow.length === 0) { - logger.error(`[${requestId}] Workflow was not created properly: ${newWorkflowId}`) - return NextResponse.json({ error: 'Failed to create workflow' }, { status: 500 }) - } - - return NextResponse.json( - { - message: 'Template used successfully', - workflowId: newWorkflowId, - workspaceId: workspaceId, - }, - { status: 201 } - ) - } catch (error: any) { - logger.error(`[${requestId}] Error using template: ${id}`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} diff --git a/apps/tradinggoose/app/api/templates/route.ts b/apps/tradinggoose/app/api/templates/route.ts deleted file mode 100644 index e2974d33b..000000000 --- a/apps/tradinggoose/app/api/templates/route.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { db } from '@tradinggoose/db' -import { templateStars, templates, workflow } from '@tradinggoose/db/schema' -import { and, desc, eq, ilike, or, sql } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { v4 as uuidv4 } from 'uuid' -import { z } from 'zod' -import { getSession } from '@/lib/auth' -import { createLogger } from '@/lib/logs/console/logger' -import { generateRequestId } from '@/lib/utils' - -const logger = createLogger('TemplatesAPI') - -export const revalidate = 0 - -// Function to sanitize sensitive data from workflow state -function sanitizeWorkflowState(state: any): any { - const sanitizedState = JSON.parse(JSON.stringify(state)) // Deep clone - - if (sanitizedState.blocks) { - Object.values(sanitizedState.blocks).forEach((block: any) => { - if (block.subBlocks) { - Object.entries(block.subBlocks).forEach(([key, subBlock]: [string, any]) => { - // Clear OAuth credentials and API keys using regex patterns - if ( - /credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(key) || - /credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test( - subBlock.type || '' - ) || - /credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test( - subBlock.value || '' - ) - ) { - subBlock.value = '' - } - }) - } - - // Also clear from data field if present - if (block.data) { - Object.entries(block.data).forEach(([key, value]: [string, any]) => { - if (/credential|oauth|api[_-]?key|token|secret|auth|password|bearer/i.test(key)) { - block.data[key] = '' - } - }) - } - }) - } - - return sanitizedState -} - -// Schema for creating a template -const CreateTemplateSchema = z.object({ - workflowId: z.string().min(1, 'Workflow ID is required'), - name: z.string().min(1, 'Name is required').max(100, 'Name must be less than 100 characters'), - description: z - .string() - .min(1, 'Description is required') - .max(500, 'Description must be less than 500 characters'), - author: z - .string() - .min(1, 'Author is required') - .max(100, 'Author must be less than 100 characters'), - category: z.string().min(1, 'Category is required'), - icon: z.string().min(1, 'Icon is required'), - color: z.string().regex(/^#[0-9A-F]{6}$/i, 'Color must be a valid hex color (e.g., #3972F6)'), - state: z.object({ - blocks: z.record(z.any()), - edges: z.array(z.any()), - loops: z.record(z.any()), - parallels: z.record(z.any()), - variables: z.record(z.any()).optional(), - }), -}) - -// Schema for query parameters -const QueryParamsSchema = z.object({ - category: z.string().optional(), - limit: z.coerce.number().optional().default(50), - offset: z.coerce.number().optional().default(0), - search: z.string().optional(), - workflowId: z.string().optional(), -}) - -// GET /api/templates - Retrieve templates -export async function GET(request: NextRequest) { - const requestId = generateRequestId() - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized templates access attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { searchParams } = new URL(request.url) - const params = QueryParamsSchema.parse(Object.fromEntries(searchParams.entries())) - - logger.debug(`[${requestId}] Fetching templates with params:`, params) - - // Build query conditions - const conditions = [] - - // Apply category filter if provided - if (params.category) { - conditions.push(eq(templates.category, params.category)) - } - - // Apply search filter if provided - if (params.search) { - const searchTerm = `%${params.search}%` - conditions.push( - or(ilike(templates.name, searchTerm), ilike(templates.description, searchTerm)) - ) - } - - // Apply workflow filter if provided (for getting template by workflow) - if (params.workflowId) { - conditions.push(eq(templates.workflowId, params.workflowId)) - } - - // Combine conditions - const whereCondition = conditions.length > 0 ? and(...conditions) : undefined - - // Apply ordering, limit, and offset with star information - const results = await db - .select({ - id: templates.id, - workflowId: templates.workflowId, - userId: templates.userId, - name: templates.name, - description: templates.description, - author: templates.author, - views: templates.views, - stars: templates.stars, - color: templates.color, - icon: templates.icon, - category: templates.category, - state: templates.state, - createdAt: templates.createdAt, - updatedAt: templates.updatedAt, - isStarred: sql`CASE WHEN ${templateStars.id} IS NOT NULL THEN true ELSE false END`, - }) - .from(templates) - .leftJoin( - templateStars, - and(eq(templateStars.templateId, templates.id), eq(templateStars.userId, session.user.id)) - ) - .where(whereCondition) - .orderBy(desc(templates.views), desc(templates.createdAt)) - .limit(params.limit) - .offset(params.offset) - - // Get total count for pagination - const totalCount = await db - .select({ count: sql`count(*)` }) - .from(templates) - .where(whereCondition) - - const total = totalCount[0]?.count || 0 - - logger.info(`[${requestId}] Successfully retrieved ${results.length} templates`) - - return NextResponse.json({ - data: results, - pagination: { - total, - limit: params.limit, - offset: params.offset, - page: Math.floor(params.offset / params.limit) + 1, - totalPages: Math.ceil(total / params.limit), - }, - }) - } catch (error: any) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid query parameters`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid query parameters', details: error.errors }, - { status: 400 } - ) - } - - logger.error(`[${requestId}] Error fetching templates`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} - -// POST /api/templates - Create a new template -export async function POST(request: NextRequest) { - const requestId = generateRequestId() - - try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized template creation attempt`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const body = await request.json() - const data = CreateTemplateSchema.parse(body) - - logger.debug(`[${requestId}] Creating template:`, { - name: data.name, - category: data.category, - workflowId: data.workflowId, - }) - - // Verify the workflow exists and belongs to the user - const workflowExists = await db - .select({ id: workflow.id }) - .from(workflow) - .where(eq(workflow.id, data.workflowId)) - .limit(1) - - if (workflowExists.length === 0) { - logger.warn(`[${requestId}] Workflow not found: ${data.workflowId}`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - // Create the template - const templateId = uuidv4() - const now = new Date() - - // Sanitize the workflow state to remove sensitive credentials - const sanitizedState = sanitizeWorkflowState(data.state) - - const newTemplate = { - id: templateId, - workflowId: data.workflowId, - userId: session.user.id, - name: data.name, - description: data.description || null, - author: data.author, - views: 0, - stars: 0, - color: data.color, - icon: data.icon, - category: data.category, - state: sanitizedState, - createdAt: now, - updatedAt: now, - } - - await db.insert(templates).values(newTemplate) - - logger.info(`[${requestId}] Successfully created template: ${templateId}`) - - return NextResponse.json( - { - id: templateId, - message: 'Template created successfully', - }, - { status: 201 } - ) - } catch (error: any) { - if (error instanceof z.ZodError) { - logger.warn(`[${requestId}] Invalid template data`, { errors: error.errors }) - return NextResponse.json( - { error: 'Invalid template data', details: error.errors }, - { status: 400 } - ) - } - - logger.error(`[${requestId}] Error creating template`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } -} 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..444363084 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, + refreshEntityListSession, +} 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,23 @@ 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 refreshEntityListSession('custom_tool', workspaceId) + await Promise.allSettled([deleteYjsSessionInSocketServer(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/users/me/settings/route.ts b/apps/tradinggoose/app/api/users/me/settings/route.ts index 15babfe24..c28da522a 100644 --- a/apps/tradinggoose/app/api/users/me/settings/route.ts +++ b/apps/tradinggoose/app/api/users/me/settings/route.ts @@ -8,10 +8,15 @@ import { z } from 'zod' import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' -import { defaultLocale, isLocaleCode, locales } from '@/i18n/utils' +import { + defaultLocale, + isLocaleCode, + LOCALE_COOKIE, + LOCALE_COOKIE_MAX_AGE, + locales, +} from '@/i18n/utils' const logger = createLogger('UserSettingsAPI') -const LOCALE_COOKIE = 'NEXT_LOCALE' const SettingsSchema = z.object({ theme: z.enum(['system', 'light', 'dark']).optional(), @@ -40,7 +45,7 @@ function withPreferredLocaleCookie(response: NextResponse, locale: string | null if (locale && isLocaleCode(locale)) { response.cookies.set(LOCALE_COOKIE, locale, { path: '/', - maxAge: 60 * 60 * 24 * 365, + maxAge: LOCALE_COOKIE_MAX_AGE, sameSite: 'lax', }) } @@ -60,8 +65,9 @@ export async function GET() { const session = await getSession() if (!session?.user?.id) { - logger.info(`[${requestId}] Returning default settings for unauthenticated user`) - return NextResponse.json({ data: defaultSettings }, { status: 200 }) + const preferredLocale = await getRuntimeLocale() + logger.info(`[${requestId}] Returning runtime settings for unauthenticated user`) + return NextResponse.json({ data: { ...defaultSettings, preferredLocale } }, { status: 200 }) } const userId = session.user.id 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/webhooks/trigger/[path]/route.test.ts b/apps/tradinggoose/app/api/webhooks/trigger/[path]/route.test.ts index 9d87de34b..ce590f345 100644 --- a/apps/tradinggoose/app/api/webhooks/trigger/[path]/route.test.ts +++ b/apps/tradinggoose/app/api/webhooks/trigger/[path]/route.test.ts @@ -248,6 +248,7 @@ describe('Webhook Trigger API Route', () => { isActive: true, providerConfig: { requireAuth: false }, workflowId: 'test-workflow-id', + blockId: 'generic-trigger-id', rateLimitCount: 100, rateLimitPeriod: 60, }) @@ -282,6 +283,7 @@ describe('Webhook Trigger API Route', () => { isActive: true, providerConfig: { requireAuth: true, token: 'test-token-123' }, workflowId: 'test-workflow-id', + blockId: 'generic-trigger-id', }) globalMockData.workflows.push({ id: 'test-workflow-id', @@ -317,6 +319,7 @@ describe('Webhook Trigger API Route', () => { secretHeaderName: 'X-Custom-Auth', }, workflowId: 'test-workflow-id', + blockId: 'generic-trigger-id', }) globalMockData.workflows.push({ id: 'test-workflow-id', @@ -348,6 +351,7 @@ describe('Webhook Trigger API Route', () => { isActive: true, providerConfig: { requireAuth: true, token: 'case-test-token' }, workflowId: 'test-workflow-id', + blockId: 'generic-trigger-id', }) globalMockData.workflows.push({ id: 'test-workflow-id', @@ -392,6 +396,7 @@ describe('Webhook Trigger API Route', () => { secretHeaderName: 'X-Secret-Key', }, workflowId: 'test-workflow-id', + blockId: 'generic-trigger-id', }) globalMockData.workflows.push({ id: 'test-workflow-id', diff --git a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts index d4d972763..a64185652 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/autolayout/route.ts @@ -1,11 +1,13 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { getSession } from '@/lib/auth' 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 { readWorkflowAccessContext } from '@/lib/workflows/utils' +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' @@ -35,10 +37,12 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ const { id: workflowId } = await params try { - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized autolayout attempt for workflow ${workflowId}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + const { error, session } = await validateWorkflowPermissions(workflowId, requestId, 'write') + if (error || !session?.user?.id) { + return NextResponse.json( + { error: error?.message ?? 'Unauthorized' }, + { status: error?.status ?? 401 } + ) } const userId = session.user.id @@ -50,44 +54,22 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ userId, }) - const accessContext = await readWorkflowAccessContext(workflowId, userId) - const workflowData = accessContext?.workflow - - if (!workflowData) { - logger.warn(`[${requestId}] Workflow ${workflowId} not found for autolayout`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - const canUpdate = - accessContext?.isOwner || - (workflowData.workspaceId - ? accessContext?.workspacePermission === 'write' || - accessContext?.workspacePermission === 'admin' - : false) + const currentWorkflowState = await requireWorkflowRealtimeState(workflowId) - if (!canUpdate) { - logger.warn( - `[${requestId}] User ${userId} denied permission to autolayout workflow ${workflowId}` - ) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + if (!currentWorkflowState) { + logger.error(`[${requestId}] Could not load workflow ${workflowId} for autolayout`) + return NextResponse.json({ error: 'Could not load workflow data' }, { status: 500 }) } - let currentWorkflowData: { blocks: Record; edges: any[] } | null + 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 = { @@ -100,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:`, { @@ -119,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 @@ -133,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..d8ac056e7 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,9 @@ 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 refreshWorkflowListForWorkflowMock: ReturnType let insertValuesMock: ReturnType let deleteWhereMock: ReturnType @@ -44,21 +43,9 @@ 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) + refreshWorkflowListForWorkflowMock = vi.fn().mockResolvedValue(undefined) insertValuesMock = vi.fn().mockResolvedValue(undefined) deleteWhereMock = vi.fn().mockResolvedValue(undefined) @@ -122,18 +109,15 @@ 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, + refreshWorkflowListForWorkflow: refreshWorkflowListForWorkflowMock, + 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 +125,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 +151,6 @@ describe('Workflow Duplicate API Route', () => { }, }, lastSaved: Date.now(), - source: 'yjs', }) const { POST } = await import('@/app/api/workflows/[id]/duplicate/route') @@ -180,42 +163,35 @@ describe('Workflow Duplicate API Route', () => { expect(response.status).toBe(201) expect(insertValuesMock).toHaveBeenCalledOnce() - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() - expect(tryApplyWorkflowStateMock).toHaveBeenCalledOnce() + expect(applyWorkflowStateMock).toHaveBeenCalledOnce() + expect(refreshWorkflowListForWorkflowMock).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(refreshWorkflowListForWorkflowMock).toHaveBeenCalledWith(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 +199,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 +210,10 @@ 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(refreshWorkflowListForWorkflowMock).not.toHaveBeenCalled() + 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 26f84daa6..e178b9428 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/duplicate/route.ts @@ -8,15 +8,16 @@ 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, + refreshWorkflowListForWorkflow, 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' @@ -25,43 +26,29 @@ const logger = createLogger('WorkflowDuplicateAPI') const DuplicateRequestSchema = z.object({ name: z.string().min(1, 'Name is required'), description: z.string().optional(), - color: z.string().optional(), workspaceId: z.string().min(1, 'Workspace ID is required'), 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), } } @@ -79,7 +66,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: try { const body = await req.json() - const { name, description, color, workspaceId, folderId } = DuplicateRequestSchema.parse(body) + const { name, description, workspaceId, folderId } = DuplicateRequestSchema.parse(body) logger.info( `[${requestId}] Duplicating workflow ${sourceWorkflowId} for user ${session.user.id}` @@ -118,17 +105,15 @@ 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() - const resolvedColor = - typeof color === 'string' && color.trim().length > 0 - ? color.trim() - : getStableVibrantColor(newWorkflowId) + const resolvedColor = getStableVibrantColor(newWorkflowId) const duplicatedWorkflowState = regenerateWorkflowStateIds(sourceArtifacts.workflowState) const duplicatedVariables = remapVariableIds(sourceArtifacts.variables, newWorkflowId) + const resolvedDescription = description || source.description await db.insert(workflow).values({ id: newWorkflowId, @@ -136,7 +121,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, @@ -144,58 +129,27 @@ 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, - duplicatedVariables, - name + createWorkflowSnapshot(duplicatedWorkflowState), + duplicatedVariables ) - 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, - } - ) + await refreshWorkflowListForWorkflow(newWorkflowId) + + 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( @@ -219,6 +173,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]/execute/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/execute/route.test.ts index 50513b392..d885774e6 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/execute/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/execute/route.test.ts @@ -470,7 +470,13 @@ describe('/api/workflows/[id]/execute', () => { expect(createHttpResponseFromBlockMock).toHaveBeenCalledWith(responseResult) }) - it('rejects non-API execution control fields on the deployed execute adapter', async () => { + it.each([ + 'workflowTriggerType', + 'triggerType', + 'executionTarget', + 'startBlockId', + 'triggerBlockId', + ])('rejects %s on the deployed execute adapter', async (field) => { const { POST } = await import('./route') const response = await POST( new NextRequest('https://example.com/api/workflows/workflow-1/execute', { @@ -479,16 +485,14 @@ describe('/api/workflows/[id]/execute', () => { 'Content-Type': 'application/json', 'X-API-Key': 'key-1', }, - body: JSON.stringify({ - workflowTriggerType: 'chat', - }), + body: JSON.stringify({ [field]: 'chat' }), }), { params: Promise.resolve({ id: 'workflow-1' }) } ) expect(response.status).toBe(400) await expect(response.json()).resolves.toMatchObject({ - error: 'Field "workflowTriggerType" is not supported by the deployed API execute endpoint', + error: `Field "${field}" is not supported by the deployed API execute endpoint`, }) expect(enqueuePendingExecutionMock).not.toHaveBeenCalled() }) diff --git a/apps/tradinggoose/app/api/workflows/[id]/execute/route.ts b/apps/tradinggoose/app/api/workflows/[id]/execute/route.ts index d904867e0..e3930f4de 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/execute/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/execute/route.ts @@ -31,13 +31,16 @@ const API_EXECUTION_POLL_INTERVAL_MS = 1_000 const API_EXECUTION_WAIT_TIMEOUT_MS = 25_000 const UNSUPPORTED_API_EXECUTE_FIELDS = [ 'workflowTriggerType', + 'triggerType', + 'executionTarget', + 'startBlockId', + 'triggerBlockId', 'isSecureMode', 'useDraftState', 'isClientSession', 'workflowData', 'workflowStateOverride', 'workflowVariables', - 'startBlockId', 'executionId', ] as const diff --git a/apps/tradinggoose/app/api/workflows/[id]/log-webhook/[webhookId]/route.ts b/apps/tradinggoose/app/api/workflows/[id]/log-webhook/[webhookId]/route.ts index 096117770..20987c9c1 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/log-webhook/[webhookId]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/log-webhook/[webhookId]/route.ts @@ -1,11 +1,11 @@ import { db } from '@tradinggoose/db' -import { permissions, workflow, workflowLogWebhook } from '@tradinggoose/db/schema' +import { workflowLogWebhook } from '@tradinggoose/db/schema' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { encryptSecret } from '@/lib/utils-server' +import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { cancelActiveWebhookDeliveries } from '../delivery-cancellation' const logger = createLogger('WorkflowLogWebhookUpdate') @@ -39,32 +39,15 @@ export async function PUT( { params }: { params: Promise<{ id: string; webhookId: string }> } ) { try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const { id: workflowId, webhookId } = await params - const userId = session.user.id - - // Check if user has access to the workflow - const hasAccess = await db - .select({ id: workflow.id }) - .from(workflow) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflow.workspaceId), - eq(permissions.userId, userId) - ) + const { error, session } = await validateWorkflowPermissions(workflowId, workflowId, 'read') + if (error || !session?.user?.id) { + return NextResponse.json( + { error: error?.message ?? 'Unauthorized' }, + { status: error?.status ?? 401 } ) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (hasAccess.length === 0) { - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } + const userId = session.user.id // Check if webhook exists and belongs to this workflow const existingWebhook = await db @@ -169,32 +152,15 @@ export async function DELETE( { params }: { params: Promise<{ id: string; webhookId: string }> } ) { try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const { id: workflowId, webhookId } = await params - const userId = session.user.id - - // Check if user has access to the workflow - const hasAccess = await db - .select({ id: workflow.id }) - .from(workflow) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflow.workspaceId), - eq(permissions.userId, userId) - ) + const { error, session } = await validateWorkflowPermissions(workflowId, workflowId, 'read') + if (error || !session?.user?.id) { + return NextResponse.json( + { error: error?.message ?? 'Unauthorized' }, + { status: error?.status ?? 401 } ) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (hasAccess.length === 0) { - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) } + const userId = session.user.id await cancelActiveWebhookDeliveries(workflowId, webhookId) diff --git a/apps/tradinggoose/app/api/workflows/[id]/log-webhook/route.ts b/apps/tradinggoose/app/api/workflows/[id]/log-webhook/route.ts index cf2fad76d..7dd2c119c 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/log-webhook/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/log-webhook/route.ts @@ -1,12 +1,12 @@ import { db } from '@tradinggoose/db' -import { permissions, workflow, workflowLogWebhook } from '@tradinggoose/db/schema' +import { workflowLogWebhook } from '@tradinggoose/db/schema' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v4 as uuidv4 } from 'uuid' import { z } from 'zod' -import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { encryptSecret } from '@/lib/utils-server' +import { validateWorkflowPermissions } from '@/lib/workflows/utils' import { cancelActiveWebhookDeliveries } from './delivery-cancellation' const logger = createLogger('WorkflowLogWebhookAPI') @@ -31,30 +31,10 @@ const CreateWebhookSchema = z.object({ export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const { id: workflowId } = await params - const userId = session.user.id - - const hasAccess = await db - .select({ id: workflow.id, userId: workflow.userId, workspaceId: workflow.workspaceId }) - .from(workflow) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflow.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (hasAccess.length === 0) { - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + const { error } = await validateWorkflowPermissions(workflowId, workflowId, 'read') + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }) } const webhooks = await db @@ -83,34 +63,12 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const { id: workflowId } = await params - const userId = session.user.id - - const hasAccess = await db - .select({ id: workflow.id, userId: workflow.userId, workspaceId: workflow.workspaceId }) - .from(workflow) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflow.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (hasAccess.length === 0) { - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + const { error } = await validateWorkflowPermissions(workflowId, workflowId, 'read') + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }) } - const workflowRecord = hasAccess[0] - const body = await request.json() const validationResult = CreateWebhookSchema.safeParse(body) @@ -194,13 +152,7 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const { id: workflowId } = await params - const userId = session.user.id const { searchParams } = new URL(request.url) const webhookId = searchParams.get('webhookId') @@ -208,22 +160,9 @@ export async function DELETE( return NextResponse.json({ error: 'webhookId is required' }, { status: 400 }) } - const hasAccess = await db - .select({ id: workflow.id }) - .from(workflow) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflow.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (hasAccess.length === 0) { - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + const { error } = await validateWorkflowPermissions(workflowId, workflowId, 'read') + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }) } await cancelActiveWebhookDeliveries(workflowId, webhookId) diff --git a/apps/tradinggoose/app/api/workflows/[id]/log-webhook/test/route.ts b/apps/tradinggoose/app/api/workflows/[id]/log-webhook/test/route.ts index 6d195813a..66a1f5049 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/log-webhook/test/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/log-webhook/test/route.ts @@ -1,12 +1,12 @@ import { createHmac } from 'crypto' import { db } from '@tradinggoose/db' -import { permissions, workflow, workflowLogWebhook } from '@tradinggoose/db/schema' +import { workflowLogWebhook } from '@tradinggoose/db/schema' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { v4 as uuidv4 } from 'uuid' -import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { decryptSecret } from '@/lib/utils-server' +import { validateWorkflowPermissions } from '@/lib/workflows/utils' const logger = createLogger('WorkflowLogWebhookTestAPI') @@ -19,13 +19,7 @@ function generateSignature(secret: string, timestamp: number, body: string): str export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - const { id: workflowId } = await params - const userId = session.user.id const { searchParams } = new URL(request.url) const webhookId = searchParams.get('webhookId') @@ -33,22 +27,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'webhookId is required' }, { status: 400 }) } - const hasAccess = await db - .select({ id: workflow.id }) - .from(workflow) - .innerJoin( - permissions, - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workflow.workspaceId), - eq(permissions.userId, userId) - ) - ) - .where(eq(workflow.id, workflowId)) - .limit(1) - - if (hasAccess.length === 0) { - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + const { error } = await validateWorkflowPermissions(workflowId, workflowId, 'read') + if (error) { + return NextResponse.json({ error: error.message }, { status: error.status }) } const [webhook] = await db diff --git a/apps/tradinggoose/app/api/workflows/[id]/queue/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/queue/route.test.ts index a6e147bce..69107e314 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/queue/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/queue/route.test.ts @@ -51,6 +51,8 @@ vi.mock('@/lib/utils', () => ({ SSE_HEADERS: { 'Content-Type': 'text/event-stream' }, })) +vi.unmock('@/blocks/registry') + import { POST } from './route' describe('POST /api/workflows/[id]/queue', () => { @@ -142,7 +144,7 @@ describe('POST /api/workflows/[id]/queue', () => { it.each([ { name: 'unsupported trigger type', - body: JSON.stringify({ triggerType: 'webhook' }), + body: JSON.stringify({ triggerType: 'api-endpoint' }), error: 'Unsupported queued workflow trigger type', }, { @@ -150,6 +152,11 @@ describe('POST /api/workflows/[id]/queue', () => { body: JSON.stringify({ executionTarget: 'draft' }), error: 'Unsupported queued workflow execution target', }, + { + name: 'webhook without live trigger block', + body: JSON.stringify({ executionTarget: 'live', triggerType: 'webhook' }), + error: 'Webhook and schedule queued workflow executions require a live trigger block', + }, { name: 'malformed JSON', body: '{', @@ -284,10 +291,10 @@ describe('POST /api/workflows/[id]/queue', () => { expect(enqueuePendingExecutionMock).not.toHaveBeenCalled() }) - it('queues editor live executions with the canonical workflow payload', async () => { + it('queues editor live executions as manual runs with trigger source metadata', async () => { const workflowData = { blocks: { - 'trigger-1': { id: 'trigger-1', type: 'manual_trigger' }, + 'trigger-1': { id: 'trigger-1', type: 'schedule' }, }, edges: [], loops: {}, @@ -304,7 +311,7 @@ describe('POST /api/workflows/[id]/queue', () => { triggerType: 'manual', workflowData, workflowVariables: { risk: { value: 1 } }, - startBlockId: 'trigger-1', + triggerBlockId: 'trigger-1', }), headers: { 'Content-Type': 'application/json', @@ -323,10 +330,12 @@ describe('POST /api/workflows/[id]/queue', () => { payload: expect.objectContaining({ executionId: 'execution-1', input: { symbol: 'AAPL' }, + triggerType: 'manual', executionTarget: 'live', workflowData, workflowVariables: { risk: { value: 1 } }, - startBlockId: 'trigger-1', + triggerBlockId: 'trigger-1', + triggerData: { source: 'schedule' }, }), }) ) diff --git a/apps/tradinggoose/app/api/workflows/[id]/queue/route.ts b/apps/tradinggoose/app/api/workflows/[id]/queue/route.ts index ba987a865..4a3a9237d 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/queue/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/queue/route.ts @@ -11,10 +11,11 @@ import { TriggerExecutionUnavailableError } from '@/lib/trigger/settings' import { generateRequestId, SSE_HEADERS } from '@/lib/utils' import type { WorkflowExecutionBlueprint } from '@/lib/workflows/execution-runner' import { readWorkflowAccessContext } from '@/lib/workflows/utils' +import type { QueuedWorkflowTriggerType } from '@/services/queue' +import { resolveTriggerExecutionIdentity } from '@/triggers/resolution' const logger = createLogger('WorkflowQueueAPI') -type QueuedWorkflowTriggerType = 'api' | 'manual' | 'chat' type QueuedWorkflowExecutionTarget = 'deployed' | 'live' type QueueRequestBody = { @@ -24,7 +25,7 @@ type QueueRequestBody = { triggerType?: unknown workflowData?: WorkflowExecutionBlueprint['workflowData'] workflowVariables?: Record - startBlockId?: string + triggerBlockId?: string selectedOutputs?: string[] stream?: boolean workflowDepth?: number @@ -32,7 +33,9 @@ type QueueRequestBody = { function readQueuedWorkflowTriggerType(value: unknown): QueuedWorkflowTriggerType | null { if (value === undefined) return 'manual' - if (value === 'api' || value === 'manual' || value === 'chat') return value + if (['api', 'manual', 'chat', 'webhook', 'schedule'].includes(value as string)) { + return value as QueuedWorkflowTriggerType + } return null } @@ -58,10 +61,36 @@ function hasLiveWorkflowState(body: QueueRequestBody) { return ( body.workflowData !== undefined || body.workflowVariables !== undefined || - (typeof body.startBlockId === 'string' && body.startBlockId.length > 0) + (typeof body.triggerBlockId === 'string' && body.triggerBlockId.length > 0) ) } +function resolveQueuedTriggerData( + body: QueueRequestBody, + executionTarget: QueuedWorkflowExecutionTarget, + triggerType: QueuedWorkflowTriggerType +) { + if (executionTarget !== 'live' || typeof body.triggerBlockId !== 'string') { + return undefined + } + + const block = body.workflowData?.blocks?.[body.triggerBlockId] + if (!block) { + throw new Error('Queued workflow trigger block was not found in live workflow state') + } + + const identity = resolveTriggerExecutionIdentity(block) + const isManualEditorRun = triggerType === 'manual' + const triggerTypeMatchesBlock = isManualEditorRun + ? identity.triggerType !== 'chat' + : identity.triggerType === triggerType + if (!triggerTypeMatchesBlock) { + throw new Error('Queued workflow trigger type does not match the trigger block') + } + + return { source: identity.triggerSource } +} + export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const requestId = generateRequestId() const { id: workflowId } = await params @@ -118,7 +147,17 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ { status: 400 } ) } - + if ( + (triggerType === 'webhook' || triggerType === 'schedule') && + (executionTarget !== 'live' || + typeof body.triggerBlockId !== 'string' || + body.triggerBlockId.length === 0) + ) { + return NextResponse.json( + { error: 'Webhook and schedule queued workflow executions require a live trigger block' }, + { status: 400 } + ) + } if ( !accessContext.isOwner && !accessContext.isWorkspaceOwner && @@ -133,6 +172,14 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ typeof body.executionId === 'string' && body.executionId.length > 0 ? body.executionId : `workflow_execution_${randomUUID()}` + let triggerData: { source: string } | undefined + try { + triggerData = resolveQueuedTriggerData(body, executionTarget, triggerType) + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Queued workflow trigger block is not runnable' + return NextResponse.json({ error: errorMessage }, { status: 400 }) + } const handle = await enqueuePendingExecution({ executionType: 'workflow', pendingExecutionId, @@ -153,12 +200,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ workflowVariables: executionTarget === 'live' ? body.workflowVariables : undefined, selectedOutputs: body.selectedOutputs, stream: body.stream === true, - startBlockId: + triggerBlockId: executionTarget === 'live' && - typeof body.startBlockId === 'string' && - body.startBlockId.length > 0 - ? body.startBlockId + typeof body.triggerBlockId === 'string' && + body.triggerBlockId.length > 0 + ? body.triggerBlockId : undefined, + ...(triggerData ? { triggerData } : {}), workflowDepth: typeof body.workflowDepth === 'number' ? body.workflowDepth : 0, metadata: { source, diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts index 3fa71eb2d..fa1f99b20 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.test.ts @@ -18,8 +18,13 @@ describe('Workflow By ID API Route', () => { const mockReadWorkflowById = vi.fn() const mockReadWorkflowAccessContext = vi.fn() - const mockDeleteYjsSessionInSocketServer = vi.fn() const mockLoadWorkflowState = vi.fn() + const mockRefreshWorkflowListForWorkflow = vi.fn() + const mockRefreshWorkflowList = vi.fn() + const mockDeleteYjsSession = vi.fn() + const mockDbUpdateReturning = vi.fn() + const mockDbUpdateWhere = vi.fn() + const mockDbUpdateSet = vi.fn() beforeEach(() => { vi.resetModules() @@ -33,7 +38,11 @@ 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, + refreshWorkflowListForWorkflow: mockRefreshWorkflowListForWorkflow, + refreshWorkflowList: mockRefreshWorkflowList, })) vi.doMock('@tradinggoose/db', () => ({ @@ -43,19 +52,16 @@ describe('Workflow By ID API Route', () => { where: vi.fn().mockResolvedValue([]), }), }), + update: vi.fn().mockReturnValue({ + set: mockDbUpdateSet, + }), }, })) vi.doMock('@tradinggoose/db/schema', () => ({ - templates: { - workflowId: 'workflowId', - id: 'id', - name: 'name', - views: 'views', - stars: 'stars', - }, workflow: { id: 'id', + folderId: 'folderId', }, })) @@ -65,13 +71,31 @@ describe('Workflow By ID API Route', () => { mockReadWorkflowById.mockReset() mockReadWorkflowAccessContext.mockReset() - mockDeleteYjsSessionInSocketServer.mockReset() mockLoadWorkflowState.mockReset() - mockDeleteYjsSessionInSocketServer.mockResolvedValue(undefined) + mockRefreshWorkflowListForWorkflow.mockReset() + mockRefreshWorkflowList.mockReset() + mockDeleteYjsSession.mockReset() + mockDbUpdateReturning.mockReset() + mockDbUpdateWhere.mockReset() + mockDbUpdateSet.mockReset() mockLoadWorkflowState.mockResolvedValue(null) + mockRefreshWorkflowListForWorkflow.mockResolvedValue(undefined) + mockDbUpdateWhere.mockReturnValue({ returning: mockDbUpdateReturning }) + mockDbUpdateSet.mockReturnValue({ where: mockDbUpdateWhere }) + mockDbUpdateReturning.mockResolvedValue([ + { + id: 'workflow-123', + name: 'Updated Workflow', + description: 'Updated description', + folderId: 'folder-1', + workspaceId: null, + }, + ]) + mockRefreshWorkflowList.mockResolvedValue(undefined) + mockDeleteYjsSession.mockResolvedValue(undefined) vi.doMock('@/lib/yjs/server/snapshot-bridge', () => ({ - deleteYjsSessionInSocketServer: mockDeleteYjsSessionInSocketServer, + deleteYjsSessionInSocketServer: mockDeleteYjsSession, })) vi.doMock('@/lib/workflows/utils', () => ({ @@ -84,6 +108,14 @@ describe('Workflow By ID API Route', () => { vi.clearAllMocks() }) + function expectWorkflowRenameApplied() { + expect(mockLoadWorkflowState).not.toHaveBeenCalled() + expect(mockDbUpdateSet).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Updated Workflow' }) + ) + expect(mockRefreshWorkflowListForWorkflow).toHaveBeenCalledWith('workflow-123') + } + describe('GET /api/workflows/[id]', () => { it('should return 401 when user is not authenticated', async () => { vi.doMock('@/lib/auth', () => ({ @@ -141,7 +173,6 @@ describe('Workflow By ID API Route', () => { edges: [], loops: {}, parallels: {}, - source: 'normalized', } vi.doMock('@/lib/auth', () => ({ @@ -194,7 +225,6 @@ describe('Workflow By ID API Route', () => { edges: [], loops: {}, parallels: {}, - source: 'normalized', } vi.doMock('@/lib/auth', () => ({ @@ -268,7 +298,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 +311,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 +342,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 +371,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 +386,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 +405,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 +428,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 +475,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 () => { @@ -497,16 +522,56 @@ describe('Workflow By ID API Route', () => { expect(response.status).toBe(200) const data = await response.json() expect(data.success).toBe(true) + expect(mockRefreshWorkflowList).toHaveBeenCalledWith('workspace-456') }) - 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 +587,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 +611,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 +623,44 @@ 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 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' } + mockDbUpdateReturning.mockResolvedValueOnce([ + { + ...mockWorkflow, + ...updateData, + updatedAt: new Date(), + }, + ]) vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ @@ -617,19 +677,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 +688,33 @@ 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(mockLoadWorkflowState).not.toHaveBeenCalled() + expect(mockDbUpdateSet).toHaveBeenCalledWith(expect.objectContaining(updateData)) + expect(mockRefreshWorkflowListForWorkflow).toHaveBeenCalledWith('workflow-123') }) - it('should allow users with write permission to update workflow', async () => { + it('updates workflow row metadata and publishes list fields', 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', + } + mockDbUpdateReturning.mockResolvedValueOnce([ + { + ...mockWorkflow, + ...updateData, + updatedAt: new Date(), + }, + ]) vi.doMock('@/lib/auth', () => ({ getSession: vi.fn().mockResolvedValue({ @@ -664,25 +725,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 +743,11 @@ 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(mockDbUpdateSet).toHaveBeenCalledWith(expect.objectContaining(updateData)) + expect(mockRefreshWorkflowListForWorkflow).toHaveBeenCalledWith('workflow-123') }) it('should deny update for users with only read permission', async () => { @@ -759,8 +812,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 +826,29 @@ describe('Workflow By ID API Route', () => { expect(response.status).toBe(400) const data = await response.json() expect(data.error).toBe('Invalid request data') + expect(mockDbUpdateSet).not.toHaveBeenCalled() + }) + + it('should reject generated workflow color updates', async () => { + vi.doMock('@/lib/auth', () => ({ + getSession: vi.fn().mockResolvedValue({ + user: { id: 'user-123' }, + }), + })) + + const req = new NextRequest('http://localhost:3000/api/workflows/workflow-123', { + method: 'PUT', + body: JSON.stringify({ color: '#3972F6' }), + }) + const params = Promise.resolve({ id: 'workflow-123' }) + + const { PUT } = await import('@/app/api/workflows/[id]/route') + const response = await PUT(req, { params }) + + expect(response.status).toBe(400) + const data = await response.json() + expect(data.error).toBe('Invalid request data') + expect(JSON.stringify(data.details)).toContain('color') }) }) diff --git a/apps/tradinggoose/app/api/workflows/[id]/route.ts b/apps/tradinggoose/app/api/workflows/[id]/route.ts index e7823b3e8..5e952eed1 100644 --- a/apps/tradinggoose/app/api/workflows/[id]/route.ts +++ b/apps/tradinggoose/app/api/workflows/[id]/route.ts @@ -1,32 +1,38 @@ import { db } from '@tradinggoose/db' -import { templates, workflow } from '@tradinggoose/db/schema' +import { workflow } from '@tradinggoose/db/schema' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' 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 { + refreshWorkflowList, + refreshWorkflowListForWorkflow, + requireWorkflowRealtimeState, +} from '@/lib/workflows/db-helpers' import { readWorkflowAccessContext, readWorkflowById } from '@/lib/workflows/utils' 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(), - description: z.string().optional(), - color: z.string().optional(), - folderId: z.string().nullable().optional(), -}) +const UpdateWorkflowSchema = z + .object({ + name: z.string().trim().min(1, 'Name is required').optional(), + description: z.string().optional(), + folderId: z.string().nullable().optional(), + }) + .strict() /** * 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() @@ -122,32 +128,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) { @@ -172,13 +175,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`) @@ -186,6 +187,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 }) } } @@ -242,60 +245,11 @@ export async function DELETE( return NextResponse.json({ error: 'Access denied' }, { status: 403 }) } - // Check if workflow has published templates before deletion - const { searchParams } = new URL(request.url) - const checkTemplates = searchParams.get('check-templates') === 'true' - const deleteTemplatesParam = searchParams.get('deleteTemplates') - - if (checkTemplates) { - // Return template information for frontend to handle - const publishedTemplates = await db - .select() - .from(templates) - .where(eq(templates.workflowId, workflowId)) - - return NextResponse.json({ - hasPublishedTemplates: publishedTemplates.length > 0, - count: publishedTemplates.length, - publishedTemplates: publishedTemplates.map((t) => ({ - id: t.id, - name: t.name, - views: t.views, - stars: t.stars, - })), - }) - } - - // Handle template deletion based on user choice - if (deleteTemplatesParam !== null) { - const deleteTemplates = deleteTemplatesParam === 'delete' - - if (deleteTemplates) { - // Delete all templates associated with this workflow - await db.delete(templates).where(eq(templates.workflowId, workflowId)) - logger.info(`[${requestId}] Deleted templates for workflow ${workflowId}`) - } else { - // Orphan the templates (set workflowId to null) - await db - .update(templates) - .set({ workflowId: null }) - .where(eq(templates.workflowId, workflowId)) - logger.info(`[${requestId}] Orphaned templates for workflow ${workflowId}`) - } - } - 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 } - ) + if (workflowData.workspaceId) { + await refreshWorkflowList(workflowData.workspaceId) } + await Promise.allSettled([deleteYjsSessionInSocketServer(workflowId)]) const elapsed = Date.now() - startTime logger.info(`[${requestId}] Successfully deleted workflow ${workflowId} in ${elapsed}ms`) @@ -304,13 +258,15 @@ export async function DELETE( } catch (error: any) { const elapsed = Date.now() - startTime logger.error(`[${requestId}] Error deleting workflow ${workflowId} after ${elapsed}ms`, error) + const realtimeResponse = createWorkflowRealtimeRequiredResponse(error) + if (realtimeResponse) return realtimeResponse return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } /** * PUT /api/workflows/[id] - * Update workflow metadata (name, description, color, folderId) + * Update workflow row metadata (name, description, folderId) */ export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const requestId = generateRequestId() @@ -367,23 +323,31 @@ 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.color !== undefined) updateData.color = updates.color - if (updates.folderId !== undefined) updateData.folderId = updates.folderId - - // Update the workflow + const rowUpdates = { + ...(updates.name !== undefined ? { name: updates.name } : {}), + ...(updates.description !== undefined ? { description: updates.description } : {}), + ...(updates.folderId !== undefined ? { folderId: updates.folderId } : {}), + updatedAt: new Date(), + } const [updatedWorkflow] = await db .update(workflow) - .set(updateData) + .set(rowUpdates) .where(eq(workflow.id, workflowId)) .returning() + if (!updatedWorkflow) { + return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) + } + if ( + updates.name !== undefined || + updates.description !== undefined || + updates.folderId !== undefined + ) { + await refreshWorkflowListForWorkflow(workflowId) + } 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 +364,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 3f16d1a48..000000000 --- a/apps/tradinggoose/app/api/workflows/[id]/state/route.test.ts +++ /dev/null @@ -1,234 +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', () => ({ - readWorkflowAccessContext: vi.fn().mockResolvedValue({ - isOwner: true, - 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 4e9b4510b..000000000 --- a/apps/tradinggoose/app/api/workflows/[id]/state/route.ts +++ /dev/null @@ -1,316 +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 { getSession } from '@/lib/auth' -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 { readWorkflowAccessContext } 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 { - // Get the session - const session = await getSession() - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthorized state update attempt for workflow ${workflowId}`) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userId = session.user.id - - // Parse and validate request body - const body = await request.json() - const state = WorkflowStateSchema.parse(body) - - // Fetch the workflow to check ownership/access - const accessContext = await readWorkflowAccessContext(workflowId, userId) - const workflowData = accessContext?.workflow - - if (!workflowData) { - logger.warn(`[${requestId}] Workflow ${workflowId} not found for state update`) - return NextResponse.json({ error: 'Workflow not found' }, { status: 404 }) - } - - // Check if user has permission to update this workflow - const canUpdate = - accessContext?.isOwner || - (workflowData.workspaceId - ? accessContext?.workspacePermission === 'write' || - accessContext?.workspacePermission === 'admin' - : false) - - if (!canUpdate) { - logger.warn( - `[${requestId}] User ${userId} denied permission to update workflow state ${workflowId}` - ) - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } - - // 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..1b1fd971a 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,16 +99,12 @@ 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: { isDeployed: true, deployedAt: null, - isPublished: false, }, }) @@ -121,7 +120,6 @@ describe('Workflow Status API Route', () => { value: 'us-west-2', }, }, - source: 'normalized', }) mockLimit.mockResolvedValue([ @@ -152,8 +150,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({ @@ -161,7 +158,6 @@ describe('Workflow Status API Route', () => { workflow: { isDeployed: true, deployedAt: null, - isPublished: false, }, }) @@ -177,7 +173,6 @@ describe('Workflow Status API Route', () => { value: 'us-west-2', }, }, - source: 'normalized', }) mockLimit.mockResolvedValue([ @@ -202,4 +197,28 @@ 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, + }, + }) + 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..7b0799f4f 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) { @@ -55,11 +59,12 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return createSuccessResponse({ isDeployed: validation.workflow.isDeployed, deployedAt: validation.workflow.deployedAt, - isPublished: validation.workflow.isPublished, needsRedeployment, }) } 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/public/[id]/route.ts b/apps/tradinggoose/app/api/workflows/public/[id]/route.ts deleted file mode 100644 index cbd4c9139..000000000 --- a/apps/tradinggoose/app/api/workflows/public/[id]/route.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { db } from '@tradinggoose/db' -import { marketplace, workflow } from '@tradinggoose/db/schema' -import { eq } from 'drizzle-orm' -import type { NextRequest } from 'next/server' -import { createLogger } from '@/lib/logs/console/logger' -import { generateRequestId } from '@/lib/utils' -import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' - -const logger = createLogger('PublicWorkflowAPI') - -// Cache response for performance -export const revalidate = 3600 - -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { - const requestId = generateRequestId() - - try { - const { id } = await params - - // First, check if the workflow exists and is published to the marketplace - const marketplaceEntry = await db - .select({ - id: marketplace.id, - workflowId: marketplace.workflowId, - state: marketplace.state, - name: marketplace.name, - description: marketplace.description, - authorId: marketplace.authorId, - authorName: marketplace.authorName, - }) - .from(marketplace) - .where(eq(marketplace.workflowId, id)) - .limit(1) - .then((rows) => rows[0]) - - if (!marketplaceEntry) { - // Check if workflow exists but is not in marketplace - const workflowExists = await db - .select({ id: workflow.id }) - .from(workflow) - .where(eq(workflow.id, id)) - .limit(1) - .then((rows) => rows.length > 0) - - if (!workflowExists) { - logger.warn(`[${requestId}] Workflow not found: ${id}`) - return createErrorResponse('Workflow not found', 404) - } - - logger.warn(`[${requestId}] Workflow exists but is not published: ${id}`) - return createErrorResponse('Workflow is not published', 403) - } - - logger.info(`[${requestId}] Retrieved public workflow: ${id}`) - - return createSuccessResponse({ - id: marketplaceEntry.workflowId, - name: marketplaceEntry.name, - description: marketplaceEntry.description, - authorId: marketplaceEntry.authorId, - authorName: marketplaceEntry.authorName, - state: marketplaceEntry.state, - isPublic: true, - }) - } catch (error) { - logger.error(`[${requestId}] Error getting public workflow: ${(await params).id}`, error) - return createErrorResponse('Failed to get public workflow', 500) - } -} diff --git a/apps/tradinggoose/app/api/workflows/route.test.ts b/apps/tradinggoose/app/api/workflows/route.test.ts index d10cb019b..98a0acc45 100644 --- a/apps/tradinggoose/app/api/workflows/route.test.ts +++ b/apps/tradinggoose/app/api/workflows/route.test.ts @@ -7,8 +7,8 @@ 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 refreshWorkflowListForWorkflowMock = vi.fn() const randomUUIDMock = vi.fn() const createRequest = (body: Record) => @@ -26,8 +26,8 @@ describe('Workflow API Route', () => { insertValuesMock.mockResolvedValue(undefined) deleteWhereMock.mockResolvedValue(undefined) - saveWorkflowToNormalizedTablesMock.mockResolvedValue({ success: true }) - tryApplyWorkflowStateMock.mockResolvedValue({ success: true }) + applyWorkflowStateMock.mockResolvedValue(undefined) + refreshWorkflowListForWorkflowMock.mockResolvedValue(undefined) randomUUIDMock.mockReset() randomUUIDMock.mockReturnValueOnce('workflow-123').mockReturnValueOnce('variable-123') vi.stubGlobal('crypto', { @@ -87,27 +87,15 @@ describe('Workflow API Route', () => { })) 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, + refreshWorkflowListForWorkflow: refreshWorkflowListForWorkflowMock, })) 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 +107,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': { @@ -138,7 +126,7 @@ describe('Workflow API Route', () => { variables: { 'var-1': { id: 'var-1', - workflowId: 'template-workflow', + workflowId: 'source-workflow', name: 'apiKey', type: 'plain', value: 'secret', @@ -158,13 +146,14 @@ describe('Workflow API Route', () => { expect(response.status).toBe(200) expect(insertValuesMock).toHaveBeenCalledOnce() - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() - expect(tryApplyWorkflowStateMock).toHaveBeenCalledOnce() + expect(applyWorkflowStateMock).toHaveBeenCalledOnce() + expect(refreshWorkflowListForWorkflowMock).toHaveBeenCalledWith('workflow-123') 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 +162,20 @@ 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 ) }) - 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 +193,37 @@ describe('Workflow API Route', () => { ) expect(response.status).toBe(500) - expect(saveWorkflowToNormalizedTablesMock).toHaveBeenCalledOnce() + expect(applyWorkflowStateMock).toHaveBeenCalledOnce() + expect(refreshWorkflowListForWorkflowMock).not.toHaveBeenCalled() 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() + expect(refreshWorkflowListForWorkflowMock).toHaveBeenCalledWith('workflow-123') + + 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: {}, + }), + {} + ) }) 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 7e1c84601..7bfb42165 100644 --- a/apps/tradinggoose/app/api/workflows/route.ts +++ b/apps/tradinggoose/app/api/workflows/route.ts @@ -8,10 +8,13 @@ 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 { refreshWorkflowListForWorkflow } 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') @@ -19,7 +22,6 @@ const logger = createLogger('WorkflowAPI') const CreateWorkflowSchema = z.object({ name: z.string().min(1, 'Name is required'), description: z.string().optional().default(''), - color: z.string().optional(), workspaceId: z.string().min(1, 'Workspace ID is required'), folderId: z.string().nullable().optional(), initialWorkflowState: z.any().optional(), @@ -35,32 +37,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, } @@ -129,7 +129,7 @@ export async function POST(req: NextRequest) { try { const body = await req.json() - const { name, description, color, workspaceId, folderId, initialWorkflowState } = + const { name, description, workspaceId, folderId, initialWorkflowState } = CreateWorkflowSchema.parse(body) const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id) @@ -158,13 +158,10 @@ 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 = - typeof color === 'string' && color.trim().length > 0 - ? color.trim() - : getStableVibrantColor(workflowId) + const resolvedColor = getStableVibrantColor(workflowId) logger.info(`[${requestId}] Creating workflow ${workflowId} for user ${session.user.id}`) @@ -194,40 +191,19 @@ 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 + ) + } catch (error) { + await db.delete(workflow).where(eq(workflow.id, workflowId)) + throw error } + await refreshWorkflowListForWorkflow(workflowId) logger.info(`[${requestId}] Successfully created workflow ${workflowId}`) @@ -242,6 +218,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]/permissions/route.test.ts b/apps/tradinggoose/app/api/workspaces/[id]/permissions/route.test.ts index 6255d0548..534fa4247 100644 --- a/apps/tradinggoose/app/api/workspaces/[id]/permissions/route.test.ts +++ b/apps/tradinggoose/app/api/workspaces/[id]/permissions/route.test.ts @@ -14,6 +14,8 @@ describe('Workspace permissions PATCH route', () => { })), })), })) + const mockAssertActiveWorkspaceAccess = vi.fn() + const mockGetUserEntityPermissions = vi.fn() const mockHasWorkspaceAdminAccess = vi.fn() const mockGetUsersWithPermissions = vi.fn() const mockAssertWorkspaceBillingOwnerRetainsAdminAccess = vi.fn() @@ -40,6 +42,7 @@ describe('Workspace permissions PATCH route', () => { }, workspace: { id: 'workspace.id', + ownerId: 'workspace.ownerId', billingOwnerType: 'workspace.billingOwnerType', billingOwnerUserId: 'workspace.billingOwnerUserId', }, @@ -65,6 +68,8 @@ describe('Workspace permissions PATCH route', () => { })) vi.doMock('@/lib/permissions/utils', () => ({ + assertActiveWorkspaceAccess: mockAssertActiveWorkspaceAccess, + getUserEntityPermissions: mockGetUserEntityPermissions, getUsersWithPermissions: mockGetUsersWithPermissions, hasWorkspaceAdminAccess: mockHasWorkspaceAdminAccess, })) @@ -73,6 +78,9 @@ describe('Workspace permissions PATCH route', () => { assertWorkspaceBillingOwnerRetainsAdminAccess: mockAssertWorkspaceBillingOwnerRetainsAdminAccess, })) + + mockAssertActiveWorkspaceAccess.mockResolvedValue({}) + mockGetUserEntityPermissions.mockResolvedValue('admin') }) afterEach(() => { @@ -89,6 +97,7 @@ describe('Workspace permissions PATCH route', () => { [{ id: 'permission-1' }], [ { + ownerId: 'owner-1', billingOwnerType: 'user', billingOwnerUserId: 'user-2', }, @@ -119,7 +128,7 @@ describe('Workspace permissions PATCH route', () => { mockGetUsersWithPermissions.mockResolvedValue([]) selectResults.push( [{ id: 'permission-1' }], - [{ billingOwnerType: 'user', billingOwnerUserId: 'user-2' }] + [{ ownerId: 'owner-1', billingOwnerType: 'user', billingOwnerUserId: 'user-2' }] ) const { PATCH } = await import('./route') @@ -138,4 +147,53 @@ describe('Workspace permissions PATCH route', () => { expect(transactionMock).not.toHaveBeenCalled() expect(mockAssertWorkspaceBillingOwnerRetainsAdminAccess).not.toHaveBeenCalled() }) + + it('rejects updates to the canonical workspace owner permission', async () => { + mockHasWorkspaceAdminAccess.mockResolvedValue(true) + selectResults.push([ + { + ownerId: 'owner-1', + billingOwnerType: 'organization', + billingOwnerUserId: null, + }, + ]) + + const { PATCH } = await import('./route') + const response = await PATCH( + new NextRequest('http://localhost/api/workspaces/workspace-1/permissions', { + method: 'PATCH', + body: JSON.stringify({ + updates: [{ userId: 'owner-1', permissions: 'write' }], + }), + }), + { params: Promise.resolve({ id: 'workspace-1' }) } + ) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: 'Workspace owner permissions are managed by workspace ownership', + }) + expect(transactionMock).not.toHaveBeenCalled() + expect(mockAssertWorkspaceBillingOwnerRetainsAdminAccess).not.toHaveBeenCalled() + }) + + it('resolves the current user permission independently from the member list', async () => { + mockGetUsersWithPermissions.mockResolvedValue([]) + mockGetUserEntityPermissions.mockResolvedValue('admin') + + const { GET } = await import('./route') + const response = await GET( + new NextRequest('http://localhost/api/workspaces/workspace-1/permissions'), + { params: Promise.resolve({ id: 'workspace-1' }) } + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + users: [], + total: 0, + currentUserPermission: 'admin', + }) + expect(mockAssertActiveWorkspaceAccess).toHaveBeenCalledWith('workspace-1', 'user-1') + expect(mockGetUserEntityPermissions).toHaveBeenCalledWith('user-1', 'workspace', 'workspace-1') + }) }) diff --git a/apps/tradinggoose/app/api/workspaces/[id]/permissions/route.ts b/apps/tradinggoose/app/api/workspaces/[id]/permissions/route.ts index 107e15cbd..f2a28fedc 100644 --- a/apps/tradinggoose/app/api/workspaces/[id]/permissions/route.ts +++ b/apps/tradinggoose/app/api/workspaces/[id]/permissions/route.ts @@ -7,6 +7,7 @@ import { getSession } from '@/lib/auth' import { createLogger } from '@/lib/logs/console/logger' import { assertActiveWorkspaceAccess, + getUserEntityPermissions, getUsersWithPermissions, hasWorkspaceAdminAccess, } from '@/lib/permissions/utils' @@ -62,10 +63,20 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } const result = await getUsersWithPermissions(workspaceId) + const currentUserPermission = await getUserEntityPermissions( + session.user.id, + 'workspace', + workspaceId + ) + + if (!currentUserPermission) { + return NextResponse.json({ error: 'Workspace permission state unavailable' }, { status: 403 }) + } return NextResponse.json({ users: result, total: result.length, + currentUserPermission, }) } catch (error) { logger.error('Error fetching workspace permissions:', error) @@ -118,6 +129,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< const workspaceRow = await db .select({ + ownerId: workspace.ownerId, billingOwnerType: workspace.billingOwnerType, billingOwnerUserId: workspace.billingOwnerUserId, }) @@ -129,6 +141,13 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) } + if (body.updates.some((update) => update.userId === workspaceRow[0].ownerId)) { + return NextResponse.json( + { error: 'Workspace owner permissions are managed by workspace ownership' }, + { status: 400 } + ) + } + try { assertWorkspaceBillingOwnerRetainsAdminAccess({ billingOwnerType: workspaceRow[0].billingOwnerType, @@ -167,11 +186,21 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< }) const updatedUsers = await getUsersWithPermissions(workspaceId) + const currentUserPermission = await getUserEntityPermissions( + session.user.id, + 'workspace', + workspaceId + ) + + if (!currentUserPermission) { + return NextResponse.json({ error: 'Workspace permission state unavailable' }, { status: 403 }) + } return NextResponse.json({ message: 'Permissions updated successfully', users: updatedUsers, total: updatedUsers.length, + currentUserPermission, }) } catch (error) { logger.error('Error updating workspace permissions:', error) diff --git a/apps/tradinggoose/app/api/workspaces/[id]/route.test.ts b/apps/tradinggoose/app/api/workspaces/[id]/route.test.ts index d1c06aff0..3c2c12d2c 100644 --- a/apps/tradinggoose/app/api/workspaces/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workspaces/[id]/route.test.ts @@ -41,7 +41,6 @@ vi.mock('@tradinggoose/db', () => ({ }, knowledgeBase: {}, permissions: {}, - templates: {}, workflow: { id: 'workflow.id', workspaceId: 'workflow.workspaceId', @@ -57,7 +56,6 @@ vi.mock('drizzle-orm', async () => { ...actual, eq: vi.fn((field, value) => ({ field, value, type: 'eq' })), and: vi.fn((...conditions) => ({ conditions, type: 'and' })), - inArray: vi.fn((field, values) => ({ field, values, type: 'inArray' })), } }) @@ -101,6 +99,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..be1e9d84d 100644 --- a/apps/tradinggoose/app/api/workspaces/[id]/route.ts +++ b/apps/tradinggoose/app/api/workspaces/[id]/route.ts @@ -1,5 +1,5 @@ -import { workflow } from '@tradinggoose/db' -import { and, eq, inArray } from 'drizzle-orm' +import { db, knowledgeBase, permissions, workflow, workspace } from '@tradinggoose/db' +import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' @@ -15,18 +15,17 @@ import { WorkspaceBillingOwnerUpdateError, workspaceBillingOwnerSchema, } from '@/lib/workspaces/billing-owner' +import { getUserWorkspaces } from '@/lib/workspaces/service' const logger = createLogger('WorkspaceByIdAPI') -import { db, knowledgeBase, permissions, templates, workspace } from '@tradinggoose/db' - const patchWorkspaceSchema = z.object({ name: z.string().trim().min(1).optional(), allowPersonalApiKeys: z.boolean().optional(), billingOwner: workspaceBillingOwnerSchema.optional(), }) -export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { +export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const { id } = await params const session = await getSession() @@ -35,9 +34,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ } const workspaceId = id - const url = new URL(request.url) - const checkTemplates = url.searchParams.get('check-templates') === 'true' - const access = await checkWorkspaceAccess(workspaceId, session.user.id) if (!access.exists || !access.hasAccess || !access.workspace) { return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) @@ -48,42 +44,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 404 }) } - // If checking for published templates before deletion - if (checkTemplates) { - try { - // Get all workflows in this workspace - const workspaceWorkflows = await db - .select({ id: workflow.id }) - .from(workflow) - .where(eq(workflow.workspaceId, workspaceId)) - - if (workspaceWorkflows.length === 0) { - return NextResponse.json({ hasPublishedTemplates: false, publishedTemplates: [] }) - } - - const workflowIds = workspaceWorkflows.map((w) => w.id) - - // Check for published templates that reference these workflows - const publishedTemplates = await db - .select({ - id: templates.id, - name: templates.name, - workflowId: templates.workflowId, - }) - .from(templates) - .where(inArray(templates.workflowId, workflowIds)) - - return NextResponse.json({ - hasPublishedTemplates: publishedTemplates.length > 0, - publishedTemplates, - count: publishedTemplates.length, - }) - } catch (error) { - logger.error(`Error checking published templates for workspace ${workspaceId}:`, error) - return NextResponse.json({ error: 'Failed to check published templates' }, { status: 500 }) - } - } - return NextResponse.json({ workspace: { ...toWorkspaceApiRecord(access.workspace), @@ -186,7 +146,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< } export async function DELETE( - request: NextRequest, + _request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params @@ -197,9 +157,6 @@ export async function DELETE( } const workspaceId = id - const body = await request.json().catch(() => ({})) - const { deleteTemplates = false } = body // User's choice: false = keep templates (recommended), true = delete templates - const existingWorkspace = await getWorkspaceById(workspaceId) if (!existingWorkspace) { return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) @@ -211,40 +168,16 @@ 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}` - ) + logger.info(`Deleting workspace ${workspaceId} for user ${session.user.id}`) // Delete workspace and all related data in a transaction await db.transaction(async (tx) => { - // Get all workflows in this workspace before deletion - const workspaceWorkflows = await tx - .select({ id: workflow.id }) - .from(workflow) - .where(eq(workflow.workspaceId, workspaceId)) - - if (workspaceWorkflows.length > 0) { - const workflowIds = workspaceWorkflows.map((w) => w.id) - - // Handle templates based on user choice - if (deleteTemplates) { - // Delete published templates that reference these workflows - await tx.delete(templates).where(inArray(templates.workflowId, workflowIds)) - logger.info(`Deleted templates for workflows in workspace ${workspaceId}`) - } else { - // Set workflowId to null for templates to create "orphaned" templates - // This allows templates to remain in marketplace but without source workflows - await tx - .update(templates) - .set({ workflowId: null }) - .where(inArray(templates.workflowId, workflowIds)) - logger.info( - `Updated templates to orphaned status for workflows in workspace ${workspaceId}` - ) - } - } - // Delete live workflow definitions first. Durable execution logs and snapshots // remain workspace-owned until the workspace row is deleted below. await tx.delete(workflow).where(eq(workflow.workspaceId, workspaceId)) diff --git a/apps/tradinggoose/app/api/workspaces/invitations/[invitationId]/route.test.ts b/apps/tradinggoose/app/api/workspaces/invitations/[invitationId]/route.test.ts index d194bf5d7..33f9fb5f4 100644 --- a/apps/tradinggoose/app/api/workspaces/invitations/[invitationId]/route.test.ts +++ b/apps/tradinggoose/app/api/workspaces/invitations/[invitationId]/route.test.ts @@ -37,6 +37,7 @@ describe('Workspace Invitation [invitationId] API Route', () => { let mockDbResults: any[] = [] let mockGetSession: any let mockHasWorkspaceAdminAccess: any + let mockCheckWorkspaceAccess: any let mockTransaction: any beforeEach(async () => { @@ -57,7 +58,9 @@ describe('Workspace Invitation [invitationId] API Route', () => { })) mockHasWorkspaceAdminAccess = vi.fn() + mockCheckWorkspaceAccess = vi.fn().mockResolvedValue({ hasAccess: false }) vi.doMock('@/lib/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, hasWorkspaceAdminAccess: mockHasWorkspaceAdminAccess, })) @@ -185,7 +188,6 @@ describe('Workspace Invitation [invitationId] API Route', () => { mockDbResults.push([mockInvitation]) mockDbResults.push([mockWorkspace]) mockDbResults.push([{ ...mockUser, email: 'invited@example.com' }]) - mockDbResults.push([]) mockTransaction.mockImplementation(async (callback: any) => { await callback({ @@ -392,6 +394,7 @@ describe('Workspace Invitation [invitationId] API Route', () => { getSession: vi.fn().mockResolvedValue({ user: mockUser }), })) vi.doMock('@/lib/permissions/utils', () => ({ + checkWorkspaceAccess: vi.fn(), hasWorkspaceAdminAccess: vi.fn(), })) vi.doMock('@/lib/env', () => { diff --git a/apps/tradinggoose/app/api/workspaces/invitations/[invitationId]/route.ts b/apps/tradinggoose/app/api/workspaces/invitations/[invitationId]/route.ts index fabec2ae6..eacc47e21 100644 --- a/apps/tradinggoose/app/api/workspaces/invitations/[invitationId]/route.ts +++ b/apps/tradinggoose/app/api/workspaces/invitations/[invitationId]/route.ts @@ -7,12 +7,12 @@ import { workspace, workspaceInvitation, } from '@tradinggoose/db/schema' -import { and, eq } from 'drizzle-orm' +import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { getEmailSubject, renderWorkspaceInvitationEmail } from '@/components/emails/render-email' import { getSession } from '@/lib/auth' import { resolveEmailLocale } from '@/lib/email/locale' -import { hasWorkspaceAdminAccess } from '@/lib/permissions/utils' +import { checkWorkspaceAccess, hasWorkspaceAdminAccess } from '@/lib/permissions/utils' import { getBaseUrl } from '@/lib/urls/utils' import { defaultLocale, localizeUrl, stripLocaleFromPathname } from '@/i18n/utils' @@ -115,19 +115,8 @@ export async function GET( return NextResponse.redirect(redirectUrl(`/invite/${invitation.id}?error=email-mismatch`)) } - const existingPermission = await db - .select() - .from(permissions) - .where( - and( - eq(permissions.entityId, invitation.workspaceId), - eq(permissions.entityType, 'workspace'), - eq(permissions.userId, session.user.id) - ) - ) - .then((rows) => rows[0]) - - if (existingPermission) { + const existingAccess = await checkWorkspaceAccess(invitation.workspaceId, session.user.id) + if (existingAccess.hasAccess) { await db .update(workspaceInvitation) .set({ diff --git a/apps/tradinggoose/app/api/workspaces/invitations/route.test.ts b/apps/tradinggoose/app/api/workspaces/invitations/route.test.ts index ea72b7e23..033a390b5 100644 --- a/apps/tradinggoose/app/api/workspaces/invitations/route.test.ts +++ b/apps/tradinggoose/app/api/workspaces/invitations/route.test.ts @@ -10,6 +10,8 @@ describe('Workspace Invitations API Route', () => { let mockGetSession: any let mockInsertValues: any let mockSendEmail: any + let mockHasWorkspaceAdminAccess: any + let mockCheckWorkspaceAccess: any beforeEach(() => { vi.resetModules() @@ -29,11 +31,14 @@ describe('Workspace Invitations API Route', () => { })) mockInsertValues = vi.fn().mockResolvedValue(undefined) + mockHasWorkspaceAdminAccess = vi.fn().mockResolvedValue(true) + mockCheckWorkspaceAccess = vi.fn().mockResolvedValue({ hasAccess: false }) const mockDbChain = { select: vi.fn().mockReturnThis(), from: vi.fn().mockReturnThis(), where: vi.fn().mockReturnThis(), innerJoin: vi.fn().mockReturnThis(), + leftJoin: vi.fn().mockReturnThis(), limit: vi.fn().mockReturnThis(), then: vi.fn().mockImplementation((callback: any) => { const result = mockDbResults.shift() || [] @@ -93,6 +98,15 @@ describe('Workspace Invitations API Route', () => { getBaseUrl: vi.fn().mockReturnValue('https://test.tradinggoose.ai'), })) + vi.doMock('@/lib/permissions/utils', () => ({ + buildWorkspaceAccessScope: vi.fn(() => ({ + permissionJoin: 'permission-join', + accessFilter: 'access-filter', + })), + checkWorkspaceAccess: mockCheckWorkspaceAccess, + hasWorkspaceAdminAccess: mockHasWorkspaceAdminAccess, + })) + vi.doMock('drizzle-orm', () => ({ and: vi.fn().mockImplementation((...args) => ({ type: 'and', conditions: args })), eq: vi.fn().mockImplementation((field, value) => ({ type: 'eq', field, value })), @@ -205,7 +219,7 @@ describe('Workspace Invitations API Route', () => { it('should return 403 when user does not have admin permissions', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) - mockDbResults = [[]] // No admin permissions found + mockHasWorkspaceAdminAccess.mockResolvedValue(false) const { POST } = await import('@/app/api/workspaces/invitations/route') const req = createMockRequest('POST', { @@ -222,7 +236,6 @@ describe('Workspace Invitations API Route', () => { it('should return 404 when workspace is not found', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) mockDbResults = [ - [{ permissionType: 'admin' }], // User has admin permissions [], // Workspace not found ] @@ -240,11 +253,10 @@ describe('Workspace Invitations API Route', () => { it('should return 400 when user already has workspace access', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) + mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true }) mockDbResults = [ - [{ permissionType: 'admin' }], // User has admin permissions [mockWorkspace], // Workspace exists [mockUser], // User exists - [{ permissionType: 'read' }], // User already has access ] const { POST } = await import('@/app/api/workspaces/invitations/route') @@ -265,7 +277,6 @@ describe('Workspace Invitations API Route', () => { it('should return 400 when invitation already exists', async () => { mockGetSession.mockResolvedValue({ user: { id: 'user-123' } }) mockDbResults = [ - [{ permissionType: 'admin' }], // User has admin permissions [mockWorkspace], // Workspace exists [], // User doesn't exist [mockInvitation], // Invitation exists @@ -291,7 +302,6 @@ describe('Workspace Invitations API Route', () => { user: { id: 'user-123', name: 'Test User', email: 'sender@example.com' }, }) mockDbResults = [ - [{ permissionType: 'admin' }], // User has admin permissions [mockWorkspace], // Workspace exists [], // User doesn't exist [], // No existing invitation diff --git a/apps/tradinggoose/app/api/workspaces/invitations/route.ts b/apps/tradinggoose/app/api/workspaces/invitations/route.ts index ff8a3f749..84fd0c0c7 100644 --- a/apps/tradinggoose/app/api/workspaces/invitations/route.ts +++ b/apps/tradinggoose/app/api/workspaces/invitations/route.ts @@ -15,6 +15,11 @@ import { getSession } from '@/lib/auth' import { resolveEmailLocale } from '@/lib/email/locale' import { sendEmail } from '@/lib/email/mailer' import { createLogger } from '@/lib/logs/console/logger' +import { + buildWorkspaceAccessScope, + checkWorkspaceAccess, + hasWorkspaceAdminAccess, +} from '@/lib/permissions/utils' import { getBaseUrl } from '@/lib/urls/utils' import { localizeUrl } from '@/i18n/utils' @@ -33,18 +38,12 @@ export async function GET(req: NextRequest) { } try { - // Get all workspaces where the user has permissions + const workspaceAccess = buildWorkspaceAccessScope(session.user.id, workspace.id) const userWorkspaces = await db .select({ id: workspace.id }) .from(workspace) - .innerJoin( - permissions, - and( - eq(permissions.entityId, workspace.id), - eq(permissions.entityType, 'workspace'), - eq(permissions.userId, session.user.id) - ) - ) + .leftJoin(permissions, workspaceAccess.permissionJoin) + .where(workspaceAccess.accessFilter) if (userWorkspaces.length === 0) { return NextResponse.json({ invitations: [] }) @@ -90,21 +89,8 @@ export async function POST(req: NextRequest) { ) } - // Check if user has admin permissions for this workspace - const userPermission = await db - .select() - .from(permissions) - .where( - and( - eq(permissions.entityId, workspaceId), - eq(permissions.entityType, 'workspace'), - eq(permissions.userId, session.user.id), - eq(permissions.permissionType, 'admin') - ) - ) - .then((rows) => rows[0]) - - if (!userPermission) { + const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, workspaceId) + if (!hasAdminAccess) { return NextResponse.json( { error: 'You need admin permissions to invite users' }, { status: 403 } @@ -131,20 +117,8 @@ export async function POST(req: NextRequest) { .then((rows) => rows[0]) if (existingUser) { - // Check if the user already has permissions for this workspace - const existingPermission = await db - .select() - .from(permissions) - .where( - and( - eq(permissions.entityId, workspaceId), - eq(permissions.entityType, 'workspace'), - eq(permissions.userId, existingUser.id) - ) - ) - .then((rows) => rows[0]) - - if (existingPermission) { + const existingAccess = await checkWorkspaceAccess(workspaceId, existingUser.id) + if (existingAccess.hasAccess) { return NextResponse.json( { error: `${email} already has access to this workspace`, diff --git a/apps/tradinggoose/app/api/workspaces/members/[id]/route.test.ts b/apps/tradinggoose/app/api/workspaces/members/[id]/route.test.ts index 5496227ca..1eb8094fa 100644 --- a/apps/tradinggoose/app/api/workspaces/members/[id]/route.test.ts +++ b/apps/tradinggoose/app/api/workspaces/members/[id]/route.test.ts @@ -4,14 +4,36 @@ import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +async function deleteMember(userId: string) { + const { DELETE } = await import('./route') + return DELETE( + new NextRequest(`http://localhost/api/workspaces/members/${userId}`, { + method: 'DELETE', + body: JSON.stringify({ workspaceId: 'workspace-1' }), + }), + { params: Promise.resolve({ id: userId }) } + ) +} + describe('Workspace member DELETE route', () => { const selectResults: any[][] = [] - const deleteMock = vi.fn() + const deleteWhereMock = vi.fn() + const deleteMock = vi.fn(() => ({ + where: deleteWhereMock, + })) const selectMock = vi.fn(() => ({ from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: vi.fn(() => selectResults.shift() ?? []), - })), + where: vi.fn(() => { + const rows = selectResults.shift() ?? [] + + return { + limit: vi.fn(() => rows), + then: ( + onFulfilled: (value: any[]) => unknown, + onRejected?: (reason: unknown) => unknown + ) => Promise.resolve(rows).then(onFulfilled, onRejected), + } + }), })), })) const mockHasWorkspaceAdminAccess = vi.fn() @@ -34,6 +56,7 @@ describe('Workspace member DELETE route', () => { }, workspace: { id: 'workspace.id', + ownerId: 'workspace.ownerId', billingOwnerType: 'workspace.billingOwnerType', billingOwnerUserId: 'workspace.billingOwnerUserId', }, @@ -70,6 +93,8 @@ describe('Workspace member DELETE route', () => { } ), })) + + mockHasWorkspaceAdminAccess.mockResolvedValue(true) }) afterEach(() => { @@ -79,25 +104,71 @@ describe('Workspace member DELETE route', () => { it('blocks removing the workspace billing owner until billing is reassigned', async () => { selectResults.push([ { + ownerId: 'user-1', billingOwnerType: 'user', billingOwnerUserId: 'user-2', }, ]) - - const { DELETE } = await import('./route') - const response = await DELETE( - new NextRequest('http://localhost/api/workspaces/members/user-2', { - method: 'DELETE', - body: JSON.stringify({ workspaceId: 'workspace-1' }), - }), - { params: Promise.resolve({ id: 'user-2' }) } - ) + const response = await deleteMember('user-2') expect(response.status).toBe(400) expect(await response.json()).toEqual({ error: 'Cannot remove the workspace billing owner. Please reassign billing first.', }) expect(deleteMock).not.toHaveBeenCalled() - expect(mockHasWorkspaceAdminAccess).not.toHaveBeenCalled() + expect(mockHasWorkspaceAdminAccess).toHaveBeenCalledWith('user-1', 'workspace-1') + }) + + it('blocks removing the canonical workspace owner', async () => { + selectResults.push([ + { + ownerId: 'user-2', + billingOwnerType: 'user', + billingOwnerUserId: 'user-1', + }, + ]) + + const response = await deleteMember('user-2') + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ error: 'Cannot remove the workspace owner' }) + expect(deleteMock).not.toHaveBeenCalled() + expect(mockHasWorkspaceAdminAccess).toHaveBeenCalledWith('user-1', 'workspace-1') + }) + + it('does not disclose canonical owner state to callers without admin access', async () => { + mockHasWorkspaceAdminAccess.mockResolvedValue(false) + selectResults.push([ + { + ownerId: 'user-2', + billingOwnerType: 'user', + billingOwnerUserId: 'user-1', + }, + ]) + + const response = await deleteMember('user-2') + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: 'Insufficient permissions' }) + expect(deleteMock).not.toHaveBeenCalled() + expect(mockHasWorkspaceAdminAccess).toHaveBeenCalledWith('user-1', 'workspace-1') + }) + + it('allows a non-owner admin to leave when the canonical owner remains admin', async () => { + selectResults.push([ + { + ownerId: 'owner-1', + billingOwnerType: 'user', + billingOwnerUserId: 'owner-1', + }, + ]) + selectResults.push([{ userId: 'user-1', permissionType: 'admin' }]) + + const response = await deleteMember('user-1') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ success: true }) + expect(deleteMock).toHaveBeenCalledWith(expect.anything()) + expect(deleteWhereMock).toHaveBeenCalled() }) }) diff --git a/apps/tradinggoose/app/api/workspaces/members/[id]/route.ts b/apps/tradinggoose/app/api/workspaces/members/[id]/route.ts index 5d95c950a..46dab9619 100644 --- a/apps/tradinggoose/app/api/workspaces/members/[id]/route.ts +++ b/apps/tradinggoose/app/api/workspaces/members/[id]/route.ts @@ -31,6 +31,7 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i const workspaceRow = await db .select({ + ownerId: workspace.ownerId, billingOwnerType: workspace.billingOwnerType, billingOwnerUserId: workspace.billingOwnerUserId, }) @@ -42,6 +43,17 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) } + const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, workspaceId) + const isSelf = userId === session.user.id + + if (!hasAdminAccess && !isSelf) { + return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) + } + + if (workspaceRow[0].ownerId === userId) { + return NextResponse.json({ error: 'Cannot remove the workspace owner' }, { status: 400 }) + } + try { assertWorkspaceBillingOwnerCanBeRemoved({ billingOwnerType: workspaceRow[0].billingOwnerType, @@ -72,36 +84,6 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i return NextResponse.json({ error: 'User not found in workspace' }, { status: 404 }) } - // Check if current user has admin access to this workspace - const hasAdminAccess = await hasWorkspaceAdminAccess(session.user.id, workspaceId) - const isSelf = userId === session.user.id - - if (!hasAdminAccess && !isSelf) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - // Prevent removing yourself if you're the last admin - if (isSelf && userPermission.permissionType === 'admin') { - const otherAdmins = await db - .select() - .from(permissions) - .where( - and( - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId), - eq(permissions.permissionType, 'admin') - ) - ) - .then((rows) => rows.filter((row) => row.userId !== session.user.id)) - - if (otherAdmins.length === 0) { - return NextResponse.json( - { error: 'Cannot remove the last admin from a workspace' }, - { status: 400 } - ) - } - } - // Delete the user's permissions for this workspace await db .delete(permissions) diff --git a/apps/tradinggoose/app/api/workspaces/members/route.ts b/apps/tradinggoose/app/api/workspaces/members/route.ts index 365dce01d..54fafe18f 100644 --- a/apps/tradinggoose/app/api/workspaces/members/route.ts +++ b/apps/tradinggoose/app/api/workspaces/members/route.ts @@ -1,9 +1,9 @@ import { db } from '@tradinggoose/db' import { permissions, type permissionTypeEnum, user } from '@tradinggoose/db/schema' -import { and, eq } from 'drizzle-orm' +import { eq } from 'drizzle-orm' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' -import { hasAdminPermission } from '@/lib/permissions/utils' +import { checkWorkspaceAccess, hasWorkspaceAdminAccess } from '@/lib/permissions/utils' type PermissionType = (typeof permissionTypeEnum.enumValues)[number] @@ -35,7 +35,7 @@ export async function POST(req: Request) { } // Check if current user has admin permission for the workspace - const hasAdmin = await hasAdminPermission(session.user.id, workspaceId) + const hasAdmin = await hasWorkspaceAdminAccess(session.user.id, workspaceId) if (!hasAdmin) { return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) @@ -52,21 +52,10 @@ export async function POST(req: Request) { return NextResponse.json({ error: 'User not found' }, { status: 404 }) } - // Check if user already has permissions for this workspace - const existingPermissions = await db - .select() - .from(permissions) - .where( - and( - eq(permissions.userId, targetUser.id), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId) - ) - ) - - if (existingPermissions.length > 0) { + const existingAccess = await checkWorkspaceAccess(workspaceId, targetUser.id) + if (existingAccess.hasAccess) { return NextResponse.json( - { error: 'User already has permissions for this workspace' }, + { error: 'User already has access to this workspace' }, { status: 400 } ) } diff --git a/apps/tradinggoose/app/api/workspaces/route.test.ts b/apps/tradinggoose/app/api/workspaces/route.test.ts index 30113d01a..c720aee00 100644 --- a/apps/tradinggoose/app/api/workspaces/route.test.ts +++ b/apps/tradinggoose/app/api/workspaces/route.test.ts @@ -1,17 +1,13 @@ /** * @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 updateWhereMock = vi.fn() - const updateSetMock = vi.fn() - const updateMock = vi.fn() let userWorkspaces: Array<{ workspace: Record - permissionType: 'admin' | 'write' | 'read' + permissionType: 'admin' | 'write' | 'read' | null }> = [] beforeEach(() => { @@ -19,14 +15,15 @@ describe('Workspaces API Route', () => { vi.clearAllMocks() userWorkspaces = [] - updateWhereMock.mockResolvedValue([]) - updateSetMock.mockReturnValue({ where: updateWhereMock }) - updateMock.mockReturnValue({ set: updateSetMock }) - vi.doMock('@tradinggoose/db', () => ({ db: { select: vi.fn(() => ({ from: vi.fn(() => ({ + leftJoin: vi.fn(() => ({ + where: vi.fn(() => ({ + orderBy: vi.fn(() => userWorkspaces), + })), + })), innerJoin: vi.fn(() => ({ where: vi.fn(() => ({ orderBy: vi.fn(() => userWorkspaces), @@ -34,8 +31,10 @@ describe('Workspaces API Route', () => { })), })), })), - update: updateMock, transaction: transactionMock, + insert: vi.fn(() => ({ + values: vi.fn().mockResolvedValue(undefined), + })), }, })) @@ -46,13 +45,9 @@ 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', createdAt: 'workspace.createdAt', }, })) @@ -73,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: vi.fn().mockResolvedValue({ success: true }), - })) - - vi.doMock('@/lib/yjs/server/apply-workflow-state', () => ({ - tryApplyWorkflowState: vi.fn().mockResolvedValue(undefined), - })) - - vi.doMock('@/lib/yjs/workflow-session', () => ({ - createWorkflowSnapshot: vi.fn(() => ({})), - })) - vi.doMock('@/lib/workspaces/billing-owner', () => ({ toWorkspaceApiRecord: vi.fn((workspace) => ({ ...workspace, @@ -113,18 +85,17 @@ describe('Workspaces API Route', () => { vi.clearAllMocks() }) - 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: { @@ -143,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) @@ -158,7 +129,39 @@ describe('Workspaces API Route', () => { role: 'owner', permissions: 'admin', }) - expect(updateMock).not.toHaveBeenCalled() + expect(transactionMock).not.toHaveBeenCalled() + }) + + it('lists owned workspaces without requiring an owner permission row', async () => { + userWorkspaces = [ + { + workspace: { + id: 'workspace-owned', + name: 'Owned Workspace', + ownerId: 'user-1', + billingOwnerType: 'user', + billingOwnerUserId: 'user-1', + billingOwnerOrganizationId: null, + createdAt: new Date('2026-04-10T00:00:00.000Z'), + updatedAt: new Date('2026-04-10T00:00:00.000Z'), + }, + permissionType: null, + }, + ] + + const { GET } = await import('@/app/api/workspaces/route') + + const response = await GET() + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.workspaces).toEqual([ + expect.objectContaining({ + id: 'workspace-owned', + role: 'owner', + permissions: 'admin', + }), + ]) expect(transactionMock).not.toHaveBeenCalled() }) }) diff --git a/apps/tradinggoose/app/api/workspaces/route.ts b/apps/tradinggoose/app/api/workspaces/route.ts index c3b2c203c..79a61c0d8 100644 --- a/apps/tradinggoose/app/api/workspaces/route.ts +++ b/apps/tradinggoose/app/api/workspaces/route.ts @@ -1,15 +1,8 @@ -import { db } from '@tradinggoose/db' -import { permissions, workflow, workspace } from '@tradinggoose/db/schema' -import { and, desc, eq, isNull } from 'drizzle-orm' -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' -import { saveWorkflowToNormalizedTables } from '@/lib/workflows/db-helpers' -import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults' -import { toWorkspaceApiRecord } from '@/lib/workspaces/billing-owner' -import { tryApplyWorkflowState } from '@/lib/yjs/server/apply-workflow-state' -import { createWorkflowSnapshot } from '@/lib/yjs/workflow-session' +import { createWorkspace, getUserWorkspaces } from '@/lib/workspaces/service' const logger = createLogger('Workspaces') const createWorkspaceSchema = z.object({ @@ -17,54 +10,18 @@ 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 }) } - // Get all workspaces where the user has permissions - const userWorkspaces = await db - .select({ - workspace: workspace, - permissionType: permissions.permissionType, - }) - .from(permissions) - .innerJoin(workspace, eq(permissions.entityId, workspace.id)) - .where(and(eq(permissions.userId, session.user.id), eq(permissions.entityType, 'workspace'))) - .orderBy(desc(workspace.createdAt)) + const workspaces = await getUserWorkspaces({ + userId: session.user.id, + }) - if (userWorkspaces.length === 0) { - if (!allowWorkspaceBootstrap) { - return NextResponse.json({ workspaces: [] }) - } - - // Create a default workspace for the user - const defaultWorkspace = await createDefaultWorkspace(session.user.id, session.user.name) - - // Migrate existing workflows to the default workspace - await migrateExistingWorkflows(session.user.id, defaultWorkspace.id) - - return NextResponse.json({ workspaces: [defaultWorkspace] }) - } - - if (allowWorkspaceBootstrap) { - // If user has workspaces but might have orphaned workflows, migrate them - await ensureWorkflowsHaveWorkspace(session.user.id, userWorkspaces[0].workspace.id) - } - - // Format the response with permission information - const workspacesWithPermissions = userWorkspaces.map( - ({ workspace: workspaceDetails, permissionType }) => ({ - ...toWorkspaceApiRecord(workspaceDetails), - role: permissionType === 'admin' ? 'owner' : 'member', // Map admin to owner for compatibility - permissions: permissionType, - }) - ) - - return NextResponse.json({ workspaces: workspacesWithPermissions }) + return NextResponse.json({ workspaces }) } // POST /api/workspaces - Create a new workspace @@ -86,165 +43,3 @@ export async function POST(req: Request) { return NextResponse.json({ error: 'Failed to create workspace' }, { status: 500 }) } } - -// Helper function to create a default workspace -async function createDefaultWorkspace(userId: string, userName?: string | null) { - const firstName = userName?.split(' ')[0] || null - const workspaceName = firstName ? `${firstName}'s Workspace` : 'My Workspace' - return createWorkspace(userId, workspaceName) -} - -// Helper function to create a workspace -async function createWorkspace(userId: string, name: string) { - const workspaceId = crypto.randomUUID() - const workflowId = crypto.randomUUID() - const now = new Date() - - // Create the workspace and initial workflow in a transaction - try { - await db.transaction(async (tx) => { - // Create the workspace - await tx.insert(workspace).values({ - id: workspaceId, - name, - ownerId: userId, - billingOwnerType: 'user', - billingOwnerUserId: userId, - billingOwnerOrganizationId: null, - allowPersonalApiKeys: true, - createdAt: now, - updatedAt: now, - }) - - // Create admin permissions for the workspace owner - await tx.insert(permissions).values({ - id: crypto.randomUUID(), - entityType: 'workspace' as const, - entityId: workspaceId, - userId: userId, - permissionType: 'admin' as const, - createdAt: now, - updatedAt: now, - }) - - // Create initial workflow for the workspace (empty canvas) - // Create the workflow - await tx.insert(workflow).values({ - id: workflowId, - userId, - workspaceId, - folderId: null, - name: 'default-agent', - description: 'Your first workflow - start building here!', - color: '#3972F6', - lastSynced: now, - createdAt: now, - updatedAt: now, - isDeployed: false, - collaborators: [], - runCount: 0, - variables: {}, - isPublished: false, - marketplaceData: null, - }) - - // No blocks are inserted - empty canvas - - logger.info( - `Created workspace ${workspaceId} with initial workflow ${workflowId} for user ${userId}` - ) - }) - - const { workflowState } = buildDefaultWorkflowArtifacts() - const lastSaved = now.toISOString() - - // Seed the Yjs doc and persist to normalized tables in parallel - const [, seedResult] = await Promise.all([ - tryApplyWorkflowState( - workflowId, - createWorkflowSnapshot({ - blocks: workflowState.blocks, - edges: workflowState.edges, - loops: workflowState.loops, - parallels: workflowState.parallels, - lastSaved, - isDeployed: false, - }), - undefined, - 'default-agent' - ), - saveWorkflowToNormalizedTables(workflowId, workflowState), - ]) - - if (!seedResult.success) { - throw new Error(seedResult.error || 'Failed to seed default workflow state') - } - } catch (error) { - logger.error(`Failed to create workspace ${workspaceId} with initial workflow:`, error) - throw error - } - - // Return the workspace data directly instead of querying again - return { - ...toWorkspaceApiRecord({ - id: workspaceId, - name, - ownerId: userId, - billingOwnerType: 'user', - billingOwnerUserId: userId, - billingOwnerOrganizationId: null, - allowPersonalApiKeys: true, - createdAt: now, - updatedAt: now, - }), - role: 'owner', - } -} - -// Helper function to migrate existing workflows to a workspace -async function migrateExistingWorkflows(userId: string, workspaceId: string) { - // Find all workflows that have no workspace ID - const orphanedWorkflows = await db - .select({ id: workflow.id }) - .from(workflow) - .where(and(eq(workflow.userId, userId), isNull(workflow.workspaceId))) - - if (orphanedWorkflows.length === 0) { - return // No orphaned workflows to migrate - } - - logger.info( - `Migrating ${orphanedWorkflows.length} workflows to workspace ${workspaceId} for user ${userId}` - ) - - // Bulk update all orphaned workflows at once - await db - .update(workflow) - .set({ - workspaceId: workspaceId, - updatedAt: new Date(), - }) - .where(and(eq(workflow.userId, userId), isNull(workflow.workspaceId))) -} - -// Helper function to ensure all workflows have a workspace -async function ensureWorkflowsHaveWorkspace(userId: string, defaultWorkspaceId: string) { - // First check if there are any orphaned workflows - const orphanedWorkflows = await db - .select() - .from(workflow) - .where(and(eq(workflow.userId, userId), isNull(workflow.workspaceId))) - - if (orphanedWorkflows.length > 0) { - // Directly update any workflows that don't have a workspace ID in a single query - await db - .update(workflow) - .set({ - workspaceId: defaultWorkspaceId, - updatedAt: new Date(), - }) - .where(and(eq(workflow.userId, userId), isNull(workflow.workspaceId))) - - logger.info(`Fixed ${orphanedWorkflows.length} orphaned workflows for user ${userId}`) - } -} 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/app-bootstrap.tsx b/apps/tradinggoose/app/app-bootstrap.tsx new file mode 100644 index 000000000..b08fa6051 --- /dev/null +++ b/apps/tradinggoose/app/app-bootstrap.tsx @@ -0,0 +1,59 @@ +'use client' + +import { useEffect } from 'react' +import { useLocale } from 'next-intl' +import { useSession } from '@/lib/auth-client' +import { useGeneralSettings } from '@/hooks/queries/general-settings' +import { replaceLocaleDocument, usePathname } from '@/i18n/navigation' +import { bootstrapProviderModels } from '@/stores/providers/store' +import { useGeneralStore } from '@/stores/settings/general/store' + +const USER_LOCALE_OWNED_ROUTE_PREFIXES = ['/workspace', '/admin', '/chat'] as const +const PROVIDER_BOOTSTRAP_DELAY_MS = 1000 + +const isUserLocaleOwnedRoute = (pathname: string) => + USER_LOCALE_OWNED_ROUTE_PREFIXES.some( + (route) => pathname === route || pathname.startsWith(`${route}/`) + ) + +export function AppBootstrap() { + const pathname = usePathname() ?? '/' + const locale = useLocale() + const { data: session, isPending } = useSession() + const userId = session?.user?.id ?? null + const settingsQuery = useGeneralSettings({ enabled: !isPending, userId }) + const preferredLocale = settingsQuery.data?.preferredLocale + + useEffect(() => { + useGeneralStore.setState({ + isLoading: isPending || (Boolean(userId) && settingsQuery.isPending), + }) + }, [isPending, settingsQuery.isPending, userId]) + + useEffect(() => { + if ( + userId && + preferredLocale && + preferredLocale !== locale && + isUserLocaleOwnedRoute(pathname) + ) { + replaceLocaleDocument(preferredLocale, `${pathname}${window.location.search}`) + } + }, [locale, pathname, preferredLocale, userId]) + + useEffect(() => { + if (!isUserLocaleOwnedRoute(pathname)) { + return + } + + const timeoutId = window.setTimeout(() => { + bootstrapProviderModels() + }, PROVIDER_BOOTSTRAP_DELAY_MS) + + return () => { + window.clearTimeout(timeoutId) + } + }, [pathname]) + + return null +} diff --git a/apps/tradinggoose/app/changelog.xml/route.ts b/apps/tradinggoose/app/changelog.xml/route.ts index 903b24dd9..8db6c30c9 100644 --- a/apps/tradinggoose/app/changelog.xml/route.ts +++ b/apps/tradinggoose/app/changelog.xml/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server' -import { SITE_BASE_URL } from '@/i18n/utils' +import { getBaseUrl } from '@/lib/urls/utils' -export const dynamic = 'force-static' +export const dynamic = 'force-dynamic' export const revalidate = 3600 interface Release { @@ -25,6 +25,7 @@ function escapeXml(str: string) { export async function GET() { try { + const siteBaseUrl = getBaseUrl() const res = await fetch( 'https://api.github.com/repos/TradingGoose/TradingGoose-Studio/releases', { @@ -52,7 +53,7 @@ export async function GET() { TradingGoose Changelog - ${SITE_BASE_URL}/changelog + ${siteBaseUrl}/changelog Latest changes, fixes and updates in TradingGoose. en-us ${items} diff --git a/apps/tradinggoose/app/llms-full.txt/route.ts b/apps/tradinggoose/app/llms-full.txt/route.ts index 8fed55dd5..cfdfe97ad 100644 --- a/apps/tradinggoose/app/llms-full.txt/route.ts +++ b/apps/tradinggoose/app/llms-full.txt/route.ts @@ -4,9 +4,12 @@ import { buildHostedPricingNarrative, buildHostedPricingSentence, } from '@/lib/billing/public-catalog' -import { SITE_BASE_URL } from '@/i18n/utils' +import { getBaseUrl } from '@/lib/urls/utils' + +export const dynamic = 'force-dynamic' export async function GET() { + const siteBaseUrl = getBaseUrl() const billingCatalog = await getPublicBillingCatalog() const hostedPricingSentence = billingCatalog.billingEnabled ? buildHostedPricingSentence(billingCatalog) @@ -31,7 +34,7 @@ export async function GET() { > information to cite TradingGoose accurately without hallucinating features, > pricing, or positioning. -Canonical URL: ${SITE_BASE_URL} +Canonical URL: ${siteBaseUrl} Source code: https://github.com/tradinggoose/tradinggoose-studio (open source, self-hostable) Documentation: https://docs.tradinggoose.ai Last updated: 2026-04-04 @@ -92,7 +95,7 @@ TradingGoose ships in two forms: - Self-hosting supported - Community-maintained -**TradingGoose Hosted (${SITE_BASE_URL})** — current managed cloud tiers: +**TradingGoose Hosted (${siteBaseUrl})** — current managed cloud tiers: ${hostedPricingTable} @@ -179,7 +182,7 @@ Calendly, Webflow, WordPress, Firecrawl, BrowserUse. **Is TradingGoose free?** Yes. TradingGoose Studio is open source under the license at -${SITE_BASE_URL}/licenses and can be self-hosted at no cost. The hosted +${siteBaseUrl}/licenses and can be self-hosted at no cost. The hosted edition at tradinggoose.ai ${hostedPricingSentence ? `currently offers ${hostedPricingSentence}.` : 'does not currently expose public billing tiers.'} Paid tiers start above the default free tier. @@ -242,11 +245,11 @@ any market data provider and stream live prices into the workspace. ## 12. Getting started -- Homepage: ${SITE_BASE_URL} +- Homepage: ${siteBaseUrl} - Documentation: https://docs.tradinggoose.ai - GitHub (open source): https://github.com/TradingGoose/TradingGoose-Studio -- Sign up (hosted, free tier): ${SITE_BASE_URL}/signup -- Changelog: ${SITE_BASE_URL}/changelog +- Sign up (hosted, free tier): ${siteBaseUrl}/signup +- Changelog: ${siteBaseUrl}/changelog ${enterpriseContactLine} ## 13. Community @@ -257,9 +260,9 @@ ${enterpriseContactLine} ## 14. Legal, licensing, and disclaimers -- Terms: ${SITE_BASE_URL}/terms -- Privacy: ${SITE_BASE_URL}/privacy -- License and third-party notices: ${SITE_BASE_URL}/licenses +- Terms: ${siteBaseUrl}/terms +- Privacy: ${siteBaseUrl}/privacy +- License and third-party notices: ${siteBaseUrl}/licenses - Support: support@tradinggoose.ai **Trading risk disclaimer:** TradingGoose is software. Trading in financial @@ -281,9 +284,9 @@ When citing TradingGoose, prefer this canonical phrasing: > can be self-hosted. Canonical sources to cite: -- Website: ${SITE_BASE_URL} +- Website: ${siteBaseUrl} - GitHub: https://github.com/tradinggoose/tradinggoose-studio -- This file: ${SITE_BASE_URL}/llms-full.txt +- This file: ${siteBaseUrl}/llms-full.txt ` return new Response(llmsFullContent, { diff --git a/apps/tradinggoose/app/llms.txt/route.ts b/apps/tradinggoose/app/llms.txt/route.ts index 27fa106f9..87a8a67fb 100644 --- a/apps/tradinggoose/app/llms.txt/route.ts +++ b/apps/tradinggoose/app/llms.txt/route.ts @@ -1,8 +1,11 @@ import { getPublicBillingCatalog } from '@/lib/billing/catalog' import { buildHostedPricingSentence } from '@/lib/billing/public-catalog' -import { SITE_BASE_URL } from '@/i18n/utils' +import { getBaseUrl } from '@/lib/urls/utils' + +export const dynamic = 'force-dynamic' export async function GET() { + const siteBaseUrl = getBaseUrl() const billingCatalog = await getPublicBillingCatalog() const hostedPricingSentence = billingCatalog.billingEnabled ? buildHostedPricingSentence(billingCatalog) @@ -53,11 +56,11 @@ ${ - Widget: a composable workspace panel (chart, indicator view, workflow status, etc.) ## Getting started -- Homepage: ${SITE_BASE_URL} +- Homepage: ${siteBaseUrl} - Documentation: https://docs.tradinggoose.ai - GitHub (open source): https://github.com/TradingGoose/TradingGoose-Studio -- Sign up (hosted): ${SITE_BASE_URL}/signup -- Changelog: ${SITE_BASE_URL}/changelog +- Sign up (hosted): ${siteBaseUrl}/signup +- Changelog: ${siteBaseUrl}/changelog ## Community - GitHub: https://github.com/TradingGoose/TradingGoose-Studio @@ -65,11 +68,11 @@ ${ - X / Twitter: https://x.com/tradinggoose ## License -See ${SITE_BASE_URL}/licenses for license and third-party notices. +See ${siteBaseUrl}/licenses for license and third-party notices. ## Full reference For a deeper, AI-readable reference (features, pricing tiers, FAQ, example -workflow, integrations, glossary), see ${SITE_BASE_URL}/llms-full.txt +workflow, integrations, glossary), see ${siteBaseUrl}/llms-full.txt ` return new Response(llmsContent, { 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/provider-models-bootstrap.tsx b/apps/tradinggoose/app/provider-models-bootstrap.tsx deleted file mode 100644 index 21c7843ce..000000000 --- a/apps/tradinggoose/app/provider-models-bootstrap.tsx +++ /dev/null @@ -1,37 +0,0 @@ -'use client' - -import { useEffect } from 'react' -import { usePathname } from '@/i18n/navigation' -import { bootstrapProviderModels } from '@/stores/providers/store' - -const PUBLIC_LANDING_ROUTE_PREFIXES = [ - '/privacy', - '/terms', - '/careers', - '/licenses', - '/blog', -] as const -const PROVIDER_BOOTSTRAP_DELAY_MS = 1000 - -const isPublicLandingRoute = (pathname: string) => - pathname === '/' || PUBLIC_LANDING_ROUTE_PREFIXES.some((route) => pathname.startsWith(route)) - -export function ProviderModelsBootstrap() { - const pathname = usePathname() ?? '/' - - useEffect(() => { - if (isPublicLandingRoute(pathname)) { - return - } - - const timeoutId = window.setTimeout(() => { - bootstrapProviderModels() - }, PROVIDER_BOOTSTRAP_DELAY_MS) - - return () => { - window.clearTimeout(timeoutId) - } - }, [pathname]) - - return null -} 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/sitemap.ts b/apps/tradinggoose/app/sitemap.ts index c75a3ddb7..213bd3057 100644 --- a/apps/tradinggoose/app/sitemap.ts +++ b/apps/tradinggoose/app/sitemap.ts @@ -3,6 +3,8 @@ import { getAllPosts } from '@/app/(landing)/blog/lib/posts' import { locales } from '@/i18n/routing' import { localizeSiteUrl } from '@/i18n/utils' +export const dynamic = 'force-dynamic' + type SitemapEntry = Omit function localizedEntries(pathname: string, entry: SitemapEntry): MetadataRoute.Sitemap { 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 })}

{ + useLayoutEffect(() => { hydratePairStoreFromColorPairs(normalizedInitialColorPairs) }, [normalizedInitialColorPairs]) @@ -494,12 +494,6 @@ export function DashboardClient({ icon: LibraryBig, href: `/workspace/${workspaceId}/knowledge`, }, - { - id: 'templates', - name: t('pages.templates'), - icon: Shapes, - href: `/workspace/${workspaceId}/templates`, - }, { id: 'docs', name: t('pages.docs'), diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/integrations/integrations.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/integrations/integrations.tsx index 6c49d3b7c..b99ff9085 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/integrations/integrations.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/integrations/integrations.tsx @@ -8,23 +8,24 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Skeleton } from '@/components/ui/skeleton' +import { createLogger } from '@/lib/logs/console/logger' +import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' +import { cn } from '@/lib/utils' +import { GlobalNavbarHeader } from '@/global-navbar' import { type ServiceInfo, useConnectOAuthService, useDisconnectOAuthService, useOAuthConnections, } from '@/hooks/queries/oauth-connections' -import { createLogger } from '@/lib/logs/console/logger' -import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' -import { cn } from '@/lib/utils' -import { GlobalNavbarHeader } from '@/global-navbar' -import { useRouter } from '@/i18n/navigation' +import { usePathname, useRouter } from '@/i18n/navigation' const logger = createLogger('Integrations') export function Integrations() { const t = useTranslations('workspace.integrations') const router = useRouter() + const pathname = usePathname() const searchParams = useSearchParams() const params = useParams() const workspaceId = params.workspaceId as string @@ -154,7 +155,7 @@ export function Integrations() { await connectService.mutateAsync({ providerId: service.providerId, - callbackURL: window.location.href, + callbackURL: `${pathname}${window.location.search}${window.location.hash}`, }) } catch (error) { logger.error('OAuth connection error:', { error }) @@ -404,8 +405,8 @@ export function Integrations() { > {t('connect')} - )} -
+ )} +
))}
) @@ -414,10 +415,10 @@ export function Integrations() { {!isLoading && !searchTerm.trim() && Object.keys(filteredGroupedServices).length === 0 && ( -
+
{t('emptyState.noConnectible')} -
- )} +
+ )} {/* Show message when search has no results */} {searchTerm.trim() && Object.keys(filteredGroupedServices).length === 0 && ( diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/base-overview/base-overview.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/base-overview/base-overview.tsx index b9635d8aa..cc0b223a4 100644 --- a/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/base-overview/base-overview.tsx +++ b/apps/tradinggoose/app/workspace/[workspaceId]/knowledge/components/base-overview/base-overview.tsx @@ -3,7 +3,7 @@ import { type KeyboardEvent, type MouseEvent, type SyntheticEvent, useState } from 'react' import { Check, Copy, LibraryBig, Loader2, Trash2 } from 'lucide-react' import { useParams } from 'next/navigation' -import { useLocale, useTranslations } from 'next-intl' +import { useTranslations } from 'next-intl' import { AlertDialog, AlertDialogAction, @@ -15,77 +15,21 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog' import { CopyToWorkspace } from '@/app/workspace/[workspaceId]/knowledge/components/copy-to-workspace/copy-to-workspace' -import { useKnowledgeStore } from '@/stores/knowledge/store' import { Link } from '@/i18n/navigation' +import { useKnowledgeStore } from '@/stores/knowledge/store' interface BaseOverviewProps { id?: string title: string - docCount: number description: string - createdAt?: string - updatedAt?: string canEdit?: boolean } -function formatRelativeTime(dateString: string, locale: string): string { - const date = new Date(dateString) - const now = new Date() - const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000) - const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }) - - if (diffInSeconds < 60) { - return rtf.format(0, 'second') - } - if (diffInSeconds < 3600) { - const minutes = Math.floor(diffInSeconds / 60) - return rtf.format(-minutes, 'minute') - } - if (diffInSeconds < 86400) { - const hours = Math.floor(diffInSeconds / 3600) - return rtf.format(-hours, 'hour') - } - if (diffInSeconds < 604800) { - const days = Math.floor(diffInSeconds / 86400) - return rtf.format(-days, 'day') - } - if (diffInSeconds < 2592000) { - const weeks = Math.floor(diffInSeconds / 604800) - return rtf.format(-weeks, 'week') - } - if (diffInSeconds < 31536000) { - const months = Math.floor(diffInSeconds / 2592000) - return rtf.format(-months, 'month') - } - const years = Math.floor(diffInSeconds / 31536000) - return rtf.format(-years, 'year') -} - -function formatAbsoluteDate(dateString: string, locale: string): string { - const date = new Date(dateString) - return date.toLocaleDateString(locale, { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }) -} - -export function BaseOverview({ - id, - title, - docCount, - description, - createdAt, - updatedAt, - canEdit = true, -}: BaseOverviewProps) { +export function BaseOverview({ id, title, description, canEdit = true }: BaseOverviewProps) { const [isCopied, setIsCopied] = useState(false) const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) const [isDeleting, setIsDeleting] = useState(false) const params = useParams() - const locale = useLocale() const t = useTranslations('workspace.knowledge.baseOverview') const workspaceSlug = params?.workspaceId as string const { removeKnowledgeBase } = useKnowledgeStore() @@ -190,10 +134,6 @@ export function BaseOverview({
- - {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 })}

- - {/* Template header */} -
-
- {/* Icon */} -
- {getIconComponent(template.icon)} -
- - {/* Title and description */} -
-

{template.name}

-

- {template.description} -

-
-
- - {/* Action buttons */} -
- {/* Star button */} - - - {/* Use template button */} - -
-
- - {/* Tags */} -
- {/* Category */} -
- {t(`categories.${template.category}`)} -
- - {/* Views */} -
- - {template.views} -
- - {/* Stars */} -
- - {starCount} -
- - {/* Author */} -
- - - {t('detail.by')} {template.author} - -
-
-
-
- - {/* Workflow preview */} -
-
-

{t('detail.workflowPreview')}

-
{renderWorkflowPreview()}
-
-
-
- ) -} diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/templates/components/navigation-tabs.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/templates/components/navigation-tabs.tsx deleted file mode 100644 index 0ec760ad7..000000000 --- a/apps/tradinggoose/app/workspace/[workspaceId]/templates/components/navigation-tabs.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { cn } from '@/lib/utils' - -interface NavigationTab { - id: string - label: string - count?: number -} - -interface NavigationTabsProps { - tabs: NavigationTab[] - activeTab?: string - onTabClick?: (tabId: string) => void - className?: string -} - -export function NavigationTabs({ tabs, activeTab, onTabClick, className }: NavigationTabsProps) { - return ( -
- {tabs.map((tab, index) => ( - - ))} -
- ) -} diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/templates/components/template-card.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/templates/components/template-card.tsx deleted file mode 100644 index 0506799c3..000000000 --- a/apps/tradinggoose/app/workspace/[workspaceId]/templates/components/template-card.tsx +++ /dev/null @@ -1,510 +0,0 @@ -import { useState } from 'react' -import { - Award, - BarChart3, - Bell, - BookOpen, - Bot, - Brain, - Briefcase, - Calculator, - Cloud, - Code, - Cpu, - CreditCard, - Database, - DollarSign, - Edit, - FileText, - Folder, - Globe, - HeadphonesIcon, - Layers, - Lightbulb, - LineChart, - Mail, - Megaphone, - MessageSquare, - NotebookPen, - Phone, - Play, - Search, - Server, - Settings, - ShoppingCart, - Star, - Target, - TrendingUp, - User, - Users, - Workflow, - Wrench, - Zap, -} from 'lucide-react' -import { useParams } from 'next/navigation' -import { useTranslations } from 'next-intl' -import { createLogger } from '@/lib/logs/console/logger' -import { useRouter } from '@/i18n/navigation' -import { sanitizeSolidIconColor } from '@/lib/ui/icon-colors' -import { cn } from '@/lib/utils' -import { getBlock } from '@/blocks/registry' - -const logger = createLogger('TemplateCard') - -// Icon mapping for template icons -const iconMap = { - // Content & Documentation - FileText, - NotebookPen, - BookOpen, - Edit, - - // Analytics & Charts - BarChart3, - LineChart, - TrendingUp, - Target, - - // Database & Storage - Database, - Server, - Cloud, - Folder, - - // Marketing & Communication - Megaphone, - Mail, - MessageSquare, - Phone, - Bell, - - // Sales & Finance - DollarSign, - CreditCard, - Calculator, - ShoppingCart, - Briefcase, - - // Support & Service - HeadphonesIcon, - User, - Users, - Settings, - Wrench, - - // AI & Technology - Bot, - Brain, - Cpu, - Code, - Zap, - - // Workflow & Process - Workflow, - Search, - Play, - Layers, - - // General - Lightbulb, - Star, - Globe, - Award, -} - -interface TemplateCardProps { - id: string - title: string - description: string - author: string - usageCount: string - stars?: number - icon?: React.ReactNode | string - iconColor?: string - blocks?: string[] - onClick?: () => void - className?: string - // Add state prop to extract block types - state?: { - blocks?: Record - } - isStarred?: boolean - // Optional callback when template is successfully used (for closing modals, etc.) - onTemplateUsed?: () => void - // Callback when star state changes (for parent state updates) - onStarChange?: (templateId: string, isStarred: boolean, newStarCount: number) => void -} - -// Skeleton component for loading states -export function TemplateCardSkeleton({ className }: { className?: string }) { - return ( -
- {/* Left side - Info skeleton */} -
- {/* Top section skeleton */} -
-
-
- {/* Icon skeleton */} -
- {/* Title skeleton */} -
-
- - {/* Star and Use button skeleton */} -
-
-
-
-
- - {/* Description skeleton */} -
-
-
-
-
-
- - {/* Bottom section skeleton */} -
-
-
-
-
-
- {/* Stars section - hidden on smaller screens */} -
-
-
-
-
-
-
- - {/* Right side - Block Icons skeleton */} -
- {Array.from({ length: 3 }).map((_, index) => ( -
- ))} -
-
- ) -} - -// Utility function to extract block types from workflow state -const extractBlockTypesFromState = (state?: { - blocks?: Record -}): string[] => { - if (!state?.blocks) return [] - - // Get unique block types from the state, excluding trigger blocks - // Sort the keys to ensure consistent ordering between server and client - const blockTypes = Object.keys(state.blocks) - .sort() // Sort keys to ensure consistent order - .map((key) => state.blocks![key].type) - .filter((type) => { - const block = getBlock(type) - return block?.category !== 'triggers' - }) - return [...new Set(blockTypes)] -} - -// Utility function to get icon component from string or return the component directly -const getIconComponent = (icon: React.ReactNode | string | undefined): React.ReactNode => { - if (typeof icon === 'string') { - const IconComponent = iconMap[icon as keyof typeof iconMap] - return IconComponent ? : - } - if (icon) { - return icon - } - // Default fallback icon - return -} - -// Utility function to get the full block config for colored icon display -const getBlockConfig = (blockType: string) => { - const block = getBlock(blockType) - return block -} - -export function TemplateCard({ - id, - title, - description, - author, - usageCount, - stars = 0, - icon, - iconColor = 'bg-blue-500', - blocks = [], - onClick, - className, - state, - isStarred = false, - onTemplateUsed, - onStarChange, -}: TemplateCardProps) { - const t = useTranslations('workspace.templates') - const router = useRouter() - const params = useParams() - - // Local state for optimistic updates - const [localIsStarred, setLocalIsStarred] = useState(isStarred) - const [localStarCount, setLocalStarCount] = useState(stars) - const [isStarLoading, setIsStarLoading] = useState(false) - - // Extract block types from state if provided, otherwise use the blocks prop - // Filter out trigger blocks in both cases and sort for consistent rendering - const blockTypes = state - ? extractBlockTypesFromState(state) - : blocks - .filter((blockType) => { - const block = getBlock(blockType) - return block?.category !== 'triggers' - }) - .sort() - - // Get the icon component - const iconComponent = getIconComponent(icon) - - // Handle star toggle with optimistic updates - const handleStarClick = async (e: React.MouseEvent) => { - e.stopPropagation() - - // Prevent multiple clicks while loading - if (isStarLoading) return - - setIsStarLoading(true) - - // Optimistic update - update UI immediately - const newIsStarred = !localIsStarred - const newStarCount = newIsStarred ? localStarCount + 1 : localStarCount - 1 - - setLocalIsStarred(newIsStarred) - setLocalStarCount(newStarCount) - - // Notify parent component immediately for optimistic update - if (onStarChange) { - onStarChange(id, newIsStarred, newStarCount) - } - - try { - const method = localIsStarred ? 'DELETE' : 'POST' - const response = await fetch(`/api/templates/${id}/star`, { method }) - - if (!response.ok) { - // Rollback on error - setLocalIsStarred(localIsStarred) - setLocalStarCount(localStarCount) - - // Rollback parent state too - if (onStarChange) { - onStarChange(id, localIsStarred, localStarCount) - } - - logger.error('Failed to toggle star:', response.statusText) - } - } catch (error) { - // Rollback on error - setLocalIsStarred(localIsStarred) - setLocalStarCount(localStarCount) - - // Rollback parent state too - if (onStarChange) { - onStarChange(id, localIsStarred, localStarCount) - } - - logger.error('Error toggling star:', error) - } finally { - setIsStarLoading(false) - } - } - - // Handle use template - const handleUseClick = async (e: React.MouseEvent) => { - e.stopPropagation() - try { - const response = await fetch(`/api/templates/${id}/use`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - workspaceId: params.workspaceId, - }), - }) - - if (response.ok) { - const data = await response.json() - logger.info('Template use API response:', data) - - if (!data.workflowId) { - logger.error('No workflowId returned from API:', data) - return - } - - const workflowUrl = `/workspace/${params.workspaceId as string}/dashboard` - logger.info('Template used successfully, navigating to:', workflowUrl) - - // Call the callback if provided (for closing modals, etc.) - if (onTemplateUsed) { - onTemplateUsed() - } - - router.push(workflowUrl) - } else { - const errorText = await response.text() - logger.error('Failed to use template:', response.statusText, errorText) - } - } catch (error) { - logger.error('Error using template:', error) - } - } - - return ( -
- {/* Left side - Info */} -
- {/* Top section */} -
-
-
- {/* Icon container */} -
-
{iconComponent}
-
- {/* Template name */} -

- {title} -

-
- - {/* Star and Use button */} -
- - -
-
- - {/* Description */} -

- {description} -

-
- - {/* Bottom section */} -
- {t('detail.by')} - {author} - • - - {usageCount} - {/* Stars section - hidden on smaller screens when space is constrained */} -
- • - - {localStarCount} -
-
-
- - {/* Right side - Block Icons */} -
- {blockTypes.length > 3 ? ( - <> - {/* Show first 2 blocks when there are more than 3 */} - {blockTypes.slice(0, 2).map((blockType, index) => { - const blockConfig = getBlockConfig(blockType) - if (!blockConfig) return null - - return ( -
-
- -
-
- ) - })} - {/* Show +n block for remaining blocks */} -
-
- +{blockTypes.length - 2} -
-
- - ) : ( - /* Show all blocks when 3 or fewer */ - blockTypes.map((blockType, index) => { - const blockConfig = getBlockConfig(blockType) - if (!blockConfig) return null - - return ( -
-
- -
-
- ) - }) - )} -
-
- ) -} diff --git a/apps/tradinggoose/app/workspace/[workspaceId]/templates/templates.tsx b/apps/tradinggoose/app/workspace/[workspaceId]/templates/templates.tsx deleted file mode 100644 index 16c03568d..000000000 --- a/apps/tradinggoose/app/workspace/[workspaceId]/templates/templates.tsx +++ /dev/null @@ -1,389 +0,0 @@ -'use client' - -import { useEffect, useRef, useState } from 'react' -import { ChevronRight, Search } from 'lucide-react' -import { useTranslations } from 'next-intl' -import { Input } from '@/components/ui/input' -import { NavigationTabs } from '@/app/workspace/[workspaceId]/templates/components/navigation-tabs' -import { - TemplateCard, - TemplateCardSkeleton, -} from '@/app/workspace/[workspaceId]/templates/components/template-card' -import type { WorkflowState } from '@/stores/workflows/workflow/types' - -// Shared categories definition -export const categories = [ - { value: 'marketing' }, - { value: 'sales' }, - { value: 'finance' }, - { value: 'support' }, - { value: 'artificial-intelligence' }, - { value: 'other' }, -] as const - -export type CategoryValue = (typeof categories)[number]['value'] - -// Template data structure -export interface Template { - id: string - workflowId: string | null - userId: string - name: string - description: string | null - author: string - views: number - stars: number - color: string - icon: string - category: CategoryValue - state: WorkflowState - createdAt: Date | string - updatedAt: Date | string - isStarred: boolean -} - -interface TemplatesProps { - initialTemplates: Template[] - currentUserId: string -} - -export default function Templates({ initialTemplates, currentUserId }: TemplatesProps) { - const t = useTranslations('workspace.templates') - const [searchQuery, setSearchQuery] = useState('') - const [activeTab, setActiveTab] = useState('your') - const [templates, setTemplates] = useState(initialTemplates) - const [loading, setLoading] = useState(false) - - // Refs for scrolling to sections - const sectionRefs = { - your: useRef(null), - recent: useRef(null), - marketing: useRef(null), - sales: useRef(null), - finance: useRef(null), - support: useRef(null), - 'artificial-intelligence': useRef(null), - other: useRef(null), - } - - // Get your templates count (created by user OR starred by user) - const yourTemplatesCount = templates.filter( - (template) => template.userId === currentUserId || template.isStarred === true - ).length - - // Handle case where active tab is "your" but user has no templates - useEffect(() => { - if (!loading && activeTab === 'your' && yourTemplatesCount === 0) { - setActiveTab('recent') // Switch to recent tab - } - }, [loading, activeTab, yourTemplatesCount]) - - const handleTabClick = (tabId: string) => { - setActiveTab(tabId) - const sectionRef = sectionRefs[tabId as keyof typeof sectionRefs] - if (sectionRef.current) { - sectionRef.current.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }) - } - } - - // Handle star change callback from template card - const handleStarChange = (templateId: string, isStarred: boolean, newStarCount: number) => { - setTemplates((prevTemplates) => - prevTemplates.map((template) => - template.id === templateId ? { ...template, isStarred, stars: newStarCount } : template - ) - ) - } - - const filteredTemplates = (category: CategoryValue | 'your' | 'recent') => { - let filteredByCategory = templates - - if (category === 'your') { - // For "your" templates, show templates created by you OR starred by you - filteredByCategory = templates.filter( - (template) => template.userId === currentUserId || template.isStarred === true - ) - } else if (category === 'recent') { - // For "recent" templates, show the 8 most recent templates - filteredByCategory = templates - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) - .slice(0, 8) - } else { - filteredByCategory = templates.filter((template) => template.category === category) - } - - if (!searchQuery) return filteredByCategory - - return filteredByCategory.filter( - (template) => - template.name.toLowerCase().includes(searchQuery.toLowerCase()) || - template.description?.toLowerCase().includes(searchQuery.toLowerCase()) || - template.author.toLowerCase().includes(searchQuery.toLowerCase()) - ) - } - - // Helper function to render template cards with proper type handling - const renderTemplateCard = (template: Template) => ( - }} - isStarred={template.isStarred} - onStarChange={handleStarChange} - /> - ) - - const getCategoryLabel = (category: CategoryValue) => t(`categories.${category}`) - - // Group templates by category for display - const getTemplatesByCategory = (category: CategoryValue | 'your' | 'recent') => { - return filteredTemplates(category) - } - - // Render skeleton cards for loading state - const renderSkeletonCards = () => { - return Array.from({ length: 8 }).map((_, index) => ( - - )) - } - - // Calculate navigation tabs with real counts or skeleton counts - const navigationTabs = [ - // Only include "Your templates" tab if user has created or starred templates - ...(yourTemplatesCount > 0 || loading - ? [ - { - id: 'your', - label: t('sections.your'), - count: loading ? 8 : getTemplatesByCategory('your').length, - }, - ] - : []), - { - id: 'recent', - label: t('sections.recent'), - count: loading ? 8 : getTemplatesByCategory('recent').length, - }, - { - id: 'marketing', - label: getCategoryLabel('marketing'), - count: loading ? 8 : getTemplatesByCategory('marketing').length, - }, - { - id: 'sales', - label: getCategoryLabel('sales'), - count: loading ? 8 : getTemplatesByCategory('sales').length, - }, - { - id: 'finance', - label: getCategoryLabel('finance'), - count: loading ? 8 : getTemplatesByCategory('finance').length, - }, - { - id: 'support', - label: getCategoryLabel('support'), - count: loading ? 8 : getTemplatesByCategory('support').length, - }, - { - id: 'artificial-intelligence', - label: getCategoryLabel('artificial-intelligence'), - count: loading ? 8 : getTemplatesByCategory('artificial-intelligence').length, - }, - { - id: 'other', - label: getCategoryLabel('other'), - count: loading ? 8 : getTemplatesByCategory('other').length, - }, - ] - - return ( -
-
-
- {/* Header */} -
-

- {t('title')} -

-

- {t('description')} -

-
- - {/* Search and Create New */} -
-
- - setSearchQuery(e.target.value)} - className='flex-1 border-0 bg-transparent px-0 font-normal font-sans text-base text-foreground leading-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0' - /> -
- {/* */} -
- - {/* Navigation */} -
- -
- - {/* Your Templates Section */} - {yourTemplatesCount > 0 || loading ? ( -
-
-

- {t('sections.your')} -

- -
- -
- {loading - ? renderSkeletonCards() - : getTemplatesByCategory('your').map((template) => renderTemplateCard(template))} -
-
- ) : null} - - {/* Recent Templates Section */} -
-
-

- {t('sections.recent')} -

- -
- -
- {loading - ? renderSkeletonCards() - : getTemplatesByCategory('recent').map((template) => renderTemplateCard(template))} -
-
- - {/* Marketing Section */} -
-
-

- {getCategoryLabel('marketing')} -

- -
- -
- {loading - ? renderSkeletonCards() - : getTemplatesByCategory('marketing').map((template) => - renderTemplateCard(template) - )} -
-
- - {/* Sales Section */} -
-
-

- {getCategoryLabel('sales')} -

- -
- -
- {loading - ? renderSkeletonCards() - : getTemplatesByCategory('sales').map((template) => renderTemplateCard(template))} -
-
- - {/* Finance Section */} -
-
-

- {getCategoryLabel('finance')} -

- -
- -
- {loading - ? renderSkeletonCards() - : getTemplatesByCategory('finance').map((template) => renderTemplateCard(template))} -
-
- - {/* Support Section */} -
-
-

- {getCategoryLabel('support')} -

- -
- -
- {loading - ? renderSkeletonCards() - : getTemplatesByCategory('support').map((template) => renderTemplateCard(template))} -
-
- - {/* Artificial Intelligence Section */} -
-
-

- {getCategoryLabel('artificial-intelligence')} -

- -
- -
- {loading - ? renderSkeletonCards() - : getTemplatesByCategory('artificial-intelligence').map((template) => - renderTemplateCard(template) - )} -
-
- - {/* Other Section */} -
-
-

- {getCategoryLabel('other')} -

- -
- -
- {loading - ? renderSkeletonCards() - : getTemplatesByCategory('other').map((template) => renderTemplateCard(template))} -
-
-
-
-
- ) -} diff --git a/apps/tradinggoose/background/indicator-monitor-execution.test.ts b/apps/tradinggoose/background/indicator-monitor-execution.test.ts index e5e47b3a4..6b4da16d9 100644 --- a/apps/tradinggoose/background/indicator-monitor-execution.test.ts +++ b/apps/tradinggoose/background/indicator-monitor-execution.test.ts @@ -125,7 +125,7 @@ describe('executeIndicatorMonitorJob', () => { workspaceId: 'workspace-1', triggerType: 'webhook', executionTarget: 'deployed', - startBlockId: 'trigger-block', + triggerBlockId: 'trigger-block', }), }) ) diff --git a/apps/tradinggoose/background/indicator-monitor-execution.ts b/apps/tradinggoose/background/indicator-monitor-execution.ts index 8391b64ca..c0d6f8dd7 100644 --- a/apps/tradinggoose/background/indicator-monitor-execution.ts +++ b/apps/tradinggoose/background/indicator-monitor-execution.ts @@ -272,7 +272,7 @@ export async function executeIndicatorMonitorJob(payload: IndicatorMonitorExecut input: budgetResult.payload, triggerType: 'webhook', executionTarget: 'deployed', - startBlockId: payload.monitor.blockId, + triggerBlockId: payload.monitor.blockId, triggerData: { source: INDICATOR_MONITOR_TRIGGER_ID, executionTarget: 'deployed', diff --git a/apps/tradinggoose/background/portfolio-monitor-execution.ts b/apps/tradinggoose/background/portfolio-monitor-execution.ts index 9c748711a..a502633e5 100644 --- a/apps/tradinggoose/background/portfolio-monitor-execution.ts +++ b/apps/tradinggoose/background/portfolio-monitor-execution.ts @@ -79,7 +79,7 @@ export async function executePortfolioMonitorJob(payload: PortfolioMonitorExecut workflowInput, executionTarget: 'deployed', workflowContext: { workspaceId: payload.monitor.workspaceId }, - start: { + triggerTarget: { kind: 'block', blockId: payload.monitor.blockId, }, diff --git a/apps/tradinggoose/background/schedule-execution.ts b/apps/tradinggoose/background/schedule-execution.ts index 7aba4e817..c8ff4891f 100644 --- a/apps/tradinggoose/background/schedule-execution.ts +++ b/apps/tradinggoose/background/schedule-execution.ts @@ -26,7 +26,7 @@ export type ScheduleExecutionPayload = { scheduleId: string workflowId: string executionId?: string - blockId?: string + blockId: string cronExpression?: string lastRanAt?: string failedCount?: number @@ -43,18 +43,19 @@ export function isScheduleExecutionPayload(value: unknown): value is ScheduleExe return ( typeof candidate.scheduleId === 'string' && typeof candidate.workflowId === 'string' && + typeof candidate.blockId === 'string' && typeof candidate.timezone === 'string' && typeof candidate.now === 'string' ) } async function calculateNextRunTime( - schedule: { cronExpression?: string; lastRanAt?: string }, + schedule: { blockId: string; cronExpression?: string; lastRanAt?: string }, blocks: Record, timezone: string ): Promise { - const scheduleBlock = Object.values(blocks).find((block) => block.type === 'schedule') - if (!scheduleBlock) throw new Error('No schedule trigger block found') + const scheduleBlock = blocks[schedule.blockId] + if (!scheduleBlock) throw new Error(`Schedule trigger block ${schedule.blockId} not found`) const scheduleType = getSubBlockValue(scheduleBlock, 'scheduleType') const scheduleValues = getScheduleTimeValues(scheduleBlock) @@ -184,10 +185,11 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { }) const scheduleBlocks = blueprint.workflowData.blocks as Record - if (payload.blockId && !scheduleBlocks[payload.blockId]) { + if (!scheduleBlocks[payload.blockId]) { logger.warn( - `[${requestId}] Schedule trigger block ${payload.blockId} not found in deployed workflow ${payload.workflowId}. Skipping execution.` + `[${requestId}] Schedule trigger block ${payload.blockId} not found in deployed workflow ${payload.workflowId}. Removing schedule.` ) + await db.delete(workflowSchedule).where(eq(workflowSchedule.id, payload.scheduleId)) return } @@ -202,9 +204,9 @@ export async function executeScheduleJob(payload: ScheduleExecutionPayload) { workflowId: payload.workflowId, }, }, - start: { + triggerTarget: { kind: 'block', - blockId: payload.blockId || undefined, + blockId: payload.blockId, }, }) diff --git a/apps/tradinggoose/background/webhook-execution.ts b/apps/tradinggoose/background/webhook-execution.ts index 3c4372f59..4629be449 100644 --- a/apps/tradinggoose/background/webhook-execution.ts +++ b/apps/tradinggoose/background/webhook-execution.ts @@ -63,7 +63,7 @@ export type WebhookExecutionPayload = { provider: string body: any headers: Record - blockId?: string + blockId: string testMode?: boolean executionTarget?: 'deployed' | 'live' } @@ -78,7 +78,8 @@ export function isWebhookExecutionPayload(value: unknown): value is WebhookExecu typeof candidate.webhookId === 'string' && typeof candidate.workflowId === 'string' && typeof candidate.userId === 'string' && - typeof candidate.provider === 'string' + typeof candidate.provider === 'string' && + typeof candidate.blockId === 'string' ) } @@ -257,7 +258,7 @@ export async function executeWebhookJob(payload: WebhookExecutionPayload) { executionId, triggerType: 'webhook', workflowInput: airtableInput, - start: { + triggerTarget: { kind: 'block', blockId: payload.blockId, }, @@ -348,7 +349,7 @@ export async function executeWebhookJob(payload: WebhookExecutionPayload) { executionId, triggerType: 'webhook', workflowInput: input || {}, - start: { + triggerTarget: { kind: 'block', blockId: payload.blockId, }, diff --git a/apps/tradinggoose/background/workflow-execution.test.ts b/apps/tradinggoose/background/workflow-execution.test.ts index c354156b5..15550a434 100644 --- a/apps/tradinggoose/background/workflow-execution.test.ts +++ b/apps/tradinggoose/background/workflow-execution.test.ts @@ -153,7 +153,7 @@ describe('executeWorkflowJob', () => { executionTarget: 'live', workflowData, workflowVariables: { risk: { value: 1 } }, - startBlockId: 'trigger-1', + triggerBlockId: 'trigger-1', metadata: { source: 'workflow_queue', }, @@ -170,7 +170,7 @@ describe('executeWorkflowJob', () => { workspaceId: 'workspace-1', variables: { risk: { value: 1 } }, }, - start: { + triggerTarget: { kind: 'block', blockId: 'trigger-1', }, @@ -178,7 +178,7 @@ describe('executeWorkflowJob', () => { ) }) - it('preserves manual queued starts when no explicit start block is supplied', async () => { + it('preserves manual queued starts when no explicit trigger block is supplied', async () => { await executeWorkflowJob({ workflowId: 'workflow-1', userId: 'user-1', @@ -191,7 +191,7 @@ describe('executeWorkflowJob', () => { expect(runWorkflowExecutionMock).toHaveBeenCalledWith( expect.objectContaining({ triggerType: 'manual', - start: { + triggerTarget: { kind: 'trigger', triggerType: 'manual', }, @@ -227,7 +227,7 @@ describe('executeWorkflowJob', () => { workflowId: 'workflow-1', userId: 'user-1', triggerType: 'webhook', - startBlockId: 'trigger-1', + triggerBlockId: 'trigger-1', triggerData: { source: 'indicator_trigger', monitor: { id: 'monitor-1' }, diff --git a/apps/tradinggoose/background/workflow-execution.ts b/apps/tradinggoose/background/workflow-execution.ts index 23d17e114..e12502e9b 100644 --- a/apps/tradinggoose/background/workflow-execution.ts +++ b/apps/tradinggoose/background/workflow-execution.ts @@ -8,14 +8,14 @@ import { createWorkflowExecutionTerminalEventInput } from '@/lib/workflows/execu import { runWorkflowExecution, type WorkflowExecutionBlueprint, - type WorkflowStart, + type WorkflowTriggerTarget, } from '@/lib/workflows/execution-runner' import type { TriggerType } from '@/services/queue' import { disableMonitor } from './monitor-disable' const logger = createLogger('TriggerWorkflowExecution') -type WorkflowStartTriggerType = Extract['triggerType'] +type WorkflowTriggerTargetType = Extract['triggerType'] export type WorkflowExecutionPayload = { workflowId: string @@ -24,7 +24,7 @@ export type WorkflowExecutionPayload = { executionId?: string input?: any triggerType?: TriggerType - startBlockId?: string + triggerBlockId?: string executionTarget?: 'deployed' | 'live' workflowData?: WorkflowExecutionBlueprint['workflowData'] workflowVariables?: Record @@ -35,11 +35,11 @@ export type WorkflowExecutionPayload = { metadata?: Record } -function resolveWorkflowStartTriggerType(triggerType: TriggerType): WorkflowStartTriggerType { +function resolveWorkflowTriggerTargetType(triggerType: TriggerType): WorkflowTriggerTargetType { if (triggerType === 'chat') return 'chat' if (triggerType === 'api' || triggerType === 'api-endpoint') return 'api' if (triggerType === 'manual') return 'manual' - throw new Error(`Queued ${triggerType} workflow execution requires an explicit start block`) + throw new Error(`Queued ${triggerType} workflow execution requires an explicit trigger block`) } export function isWorkflowExecutionPayload( @@ -68,14 +68,14 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) { const isLiveExecution = executionTarget === 'live' const isChildExecution = payload.metadata?.source === 'workflow_block' const triggerType = payload.triggerType ?? 'manual' - const start: WorkflowStart = payload.startBlockId + const triggerTarget: WorkflowTriggerTarget = payload.triggerBlockId ? { kind: 'block', - blockId: payload.startBlockId, + blockId: payload.triggerBlockId, } : { kind: 'trigger', - triggerType: resolveWorkflowStartTriggerType(triggerType), + triggerType: resolveWorkflowTriggerTargetType(triggerType), } logger.info(`[${requestId}] Starting workflow execution: ${workflowId}`, { @@ -112,7 +112,7 @@ export async function executeWorkflowJob(payload: WorkflowExecutionPayload) { } : undefined, workflowData: isLiveExecution ? payload.workflowData : undefined, - start, + triggerTarget, triggerData, contextExtensions: { workflowDepth: payload.workflowDepth ?? 0, diff --git a/apps/tradinggoose/blocks/blocks/agent.test.ts b/apps/tradinggoose/blocks/blocks/agent.test.ts index 18eb0e3fa..fb4e559be 100644 --- a/apps/tradinggoose/blocks/blocks/agent.test.ts +++ b/apps/tradinggoose/blocks/blocks/agent.test.ts @@ -114,9 +114,9 @@ describe('AgentBlock', () => { { type: 'custom-tool', title: 'Custom Tool', + toolId: 'custom_custom-tool-1', schema: { function: { - name: 'custom_function', description: 'A custom function', parameters: { type: 'object', properties: {} }, }, @@ -169,9 +169,9 @@ describe('AgentBlock', () => { { type: 'custom-tool', title: 'Custom Tool', + toolId: 'custom_custom-tool-1', schema: { function: { - name: 'custom_function', description: 'A custom function description', parameters: { type: 'object', @@ -190,7 +190,7 @@ describe('AgentBlock', () => { // Verify custom tool transformation expect(result.tools[0]).toEqual({ - id: 'custom_function', + id: 'custom_custom-tool-1', name: 'Custom Tool', description: 'A custom function description', params: {}, diff --git a/apps/tradinggoose/blocks/blocks/agent.ts b/apps/tradinggoose/blocks/blocks/agent.ts index 82d61c4ae..04163329f 100644 --- a/apps/tradinggoose/blocks/blocks/agent.ts +++ b/apps/tradinggoose/blocks/blocks/agent.ts @@ -1,4 +1,5 @@ import { AgentIcon } from '@/components/icons/icons' +import { getCustomToolEntityIdFromRuntimeId } from '@/lib/custom-tools/schema' import { isHosted } from '@/lib/environment' import { createLogger } from '@/lib/logs/console/logger' import type { BlockConfig, SubBlockOption, SubBlockOptionGroup } from '@/blocks/types' @@ -480,15 +481,17 @@ Example 3 (Array Input): return usageControl !== 'none' }) .map((tool: any) => { + const isCustomTool = tool.type === 'custom-tool' + const toolId = isCustomTool + ? tool.toolId + : tool.operation || getToolIdFromBlock(tool.type) + if (isCustomTool) getCustomToolEntityIdFromRuntimeId(toolId) const toolConfig = { - id: - tool.type === 'custom-tool' - ? tool.schema?.function?.name - : tool.operation || getToolIdFromBlock(tool.type), - name: tool.title, - description: tool.type === 'custom-tool' ? tool.schema?.function?.description : '', + id: toolId, + name: isCustomTool ? tool.title?.trim() : tool.title, + description: isCustomTool ? tool.schema?.function?.description : '', params: tool.params || {}, - parameters: tool.type === 'custom-tool' ? tool.schema?.function?.parameters : {}, + parameters: isCustomTool ? tool.schema?.function?.parameters : {}, usageControl: tool.usageControl || 'auto', type: tool.type, } diff --git a/apps/tradinggoose/blocks/blocks/function.ts b/apps/tradinggoose/blocks/blocks/function.ts index 808b9959b..e9eaa2c8d 100644 --- a/apps/tradinggoose/blocks/blocks/function.ts +++ b/apps/tradinggoose/blocks/blocks/function.ts @@ -7,14 +7,14 @@ export const FunctionBlock: BlockConfig = { name: 'Function', description: 'Run custom logic', longDescription: - 'This is a core workflow block. Execute custom TypeScript code within your workflow. Code transpiles to JavaScript at runtime and executes on E2B when enabled, otherwise local VM. Available indicators are executed through indicator.(marketSeries) with full Historical Data block output.', + 'This is a core workflow block. Execute custom TypeScript code within your workflow. Code transpiles to JavaScript at runtime and executes on E2B when enabled, otherwise local VM. Available indicators are executed through indicator.(marketSeries) or indicator[""](marketSeries) with full Historical Data block output.', bestPractices: ` - Write TypeScript statements only (no function wrapper). - If you need external imports, enable E2B at the environment level. - Do not define Pine indicators directly in this block (no indicator(...), PineTS, or pinets imports). - - To execute available indicators, call indicator.(marketSeries) with the full Historical Data output, not . Example: await indicator.RSI(). + - To execute available indicators, call indicator.(marketSeries) or indicator[""](marketSeries) with the full Historical Data output, not . Example: await indicator.RSI(). - Indicator params must be passed as an object. Use saved input titles as keys, for example: await indicator.RSI(, { Length: 7 }) or await indicator.RSI(, { inputs: { Length: 7 } }). - - Use indicator.list() if you need to inspect supported available indicator IDs before calling one. + - Use indicator.list() if you need to inspect available indicator IDs before calling one. - Reference upstream outputs by copying exact TradingGoose tags like , workflow variables like , and environment variables with {{ENV_VAR_NAME}}. These references are valid Function code and resolve before execution; avoid arbitrary XML/HTML tags. `, docsLink: 'https://docs.tradinggoose.ai/blocks/function', @@ -47,7 +47,7 @@ IMPORTANT FORMATTING RULES: 6. Output: Ensure the code returns a value if the function is expected to produce output. Use 'return'. 7. Clarity: Write clean, readable code. 8. No Explanations: Do NOT include markdown formatting, comments explaining the rules, or any text other than the raw TypeScript code for the function body. -9. Available indicators only: Do NOT define indicators directly with indicator(...) or pinets imports. Use indicator.(marketSeries) with the full Historical Data output, not . The optional second argument must be an object, e.g. await indicator.RSI(, { Length: 7 }). Use indicator.list() if the available ID is unknown. +9. Available indicators only: Do NOT define indicators directly with indicator(...) or pinets imports. Use indicator.(marketSeries) or indicator[""](marketSeries) with the full Historical Data output, not . The optional second argument must be an object, e.g. await indicator.RSI(, { Length: 7 }). Use indicator.list() if the available ID is unknown. Example Scenario: User Prompt: "Fetch user data from an API. Use the User ID passed in as 'userId' and an API Key stored as the 'SERVICE_API_KEY' environment variable." diff --git a/apps/tradinggoose/blocks/blocks/workflow.ts b/apps/tradinggoose/blocks/blocks/workflow.ts index d8d647a1e..6d9158799 100644 --- a/apps/tradinggoose/blocks/blocks/workflow.ts +++ b/apps/tradinggoose/blocks/blocks/workflow.ts @@ -1,37 +1,5 @@ import { WorkflowIcon } from '@/components/icons/icons' -import { createLogger } from '@/lib/logs/console/logger' -import type { BlockConfig, BlockOptionLoaderContext } from '@/blocks/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' - -const logger = createLogger('WorkflowBlock') - -// Helper function to get available workflows for the dropdown -const getAvailableWorkflows = (channelId: string): Array<{ label: string; id: string }> => { - try { - const { workflows } = useWorkflowRegistry.getState() - const activeWorkflowId = useWorkflowRegistry.getState().getActiveWorkflowId(channelId) - - // Filter out the current workflow to prevent recursion - const availableWorkflows = Object.entries(workflows) - .filter(([id]) => id !== activeWorkflowId) - .map(([id, workflow]) => ({ - label: workflow.name || `Workflow ${id.slice(0, 8)}`, - id: id, - })) - .sort((a, b) => a.label.localeCompare(b.label)) - - return availableWorkflows - } catch (error) { - logger.error('Error getting available workflows:', error) - return [] - } -} - -const fetchAvailableWorkflows = async ( - _blockId: string, - _subBlockId: string, - context: BlockOptionLoaderContext -) => getAvailableWorkflows(context.channelId) +import type { BlockConfig } from '@/blocks/types' export const WorkflowBlock: BlockConfig = { type: 'workflow', @@ -46,7 +14,6 @@ export const WorkflowBlock: BlockConfig = { id: 'workflowId', title: 'Select Workflow', type: 'dropdown', - fetchOptions: fetchAvailableWorkflows, required: true, }, { diff --git a/apps/tradinggoose/blocks/blocks/workflow_input.ts b/apps/tradinggoose/blocks/blocks/workflow_input.ts index 49a970627..64593ee05 100644 --- a/apps/tradinggoose/blocks/blocks/workflow_input.ts +++ b/apps/tradinggoose/blocks/blocks/workflow_input.ts @@ -1,26 +1,5 @@ import { WorkflowIcon } from '@/components/icons/icons' -import type { BlockConfig, BlockOptionLoaderContext } from '@/blocks/types' -import { useWorkflowRegistry } from '@/stores/workflows/registry/store' - -// Helper: list workflows excluding self -const getAvailableWorkflows = (channelId: string): Array<{ label: string; id: string }> => { - try { - const { workflows } = useWorkflowRegistry.getState() - const activeWorkflowId = useWorkflowRegistry.getState().getActiveWorkflowId(channelId) - return Object.entries(workflows) - .filter(([id]) => id !== activeWorkflowId) - .map(([id, w]) => ({ label: w.name || `Workflow ${id.slice(0, 8)}`, id })) - .sort((a, b) => a.label.localeCompare(b.label)) - } catch { - return [] - } -} - -const fetchAvailableWorkflows = async ( - _blockId: string, - _subBlockId: string, - context: BlockOptionLoaderContext -) => getAvailableWorkflows(context.channelId) +import type { BlockConfig } from '@/blocks/types' // New workflow block variant that visualizes child Input Trigger schema for mapping export const WorkflowInputBlock: BlockConfig = { @@ -40,7 +19,6 @@ export const WorkflowInputBlock: BlockConfig = { id: 'workflowId', title: 'Select Workflow', type: 'dropdown', - fetchOptions: fetchAvailableWorkflows, required: true, }, // Renders dynamic mapping UI based on selected child workflow's Input Trigger inputFormat diff --git a/apps/tradinggoose/components/emails/header.tsx b/apps/tradinggoose/components/emails/header.tsx index 0578b0610..b948b0b1c 100644 --- a/apps/tradinggoose/components/emails/header.tsx +++ b/apps/tradinggoose/components/emails/header.tsx @@ -6,13 +6,13 @@ import { getBaseUrl } from '@/lib/urls/utils' import { type EmailLocale, getEmailCopy } from '@/components/emails/email-copy' interface EmailHeaderProps { + baseUrl?: string tagline?: string locale?: EmailLocale } -export const EmailHeader = ({ tagline, locale }: EmailHeaderProps) => { +export const EmailHeader = ({ baseUrl = getBaseUrl(), tagline, locale }: EmailHeaderProps) => { const brand = getBrandConfig() - const baseUrl = getBaseUrl() const logoSrc = `${baseUrl}/favicon/goose.png` const copy = getEmailCopy(locale) const resolvedTagline = tagline ?? copy.shared.tagline diff --git a/apps/tradinggoose/components/emails/localized-email.tsx b/apps/tradinggoose/components/emails/localized-email.tsx index 11d8c07c3..4bc212dfa 100644 --- a/apps/tradinggoose/components/emails/localized-email.tsx +++ b/apps/tradinggoose/components/emails/localized-email.tsx @@ -49,7 +49,7 @@ export function LocalizedEmail({ {preview} - +
{title} diff --git a/apps/tradinggoose/components/oauth/oauth-required-modal.test.tsx b/apps/tradinggoose/components/oauth/oauth-required-modal.test.tsx index d28451110..31f08b000 100644 --- a/apps/tradinggoose/components/oauth/oauth-required-modal.test.tsx +++ b/apps/tradinggoose/components/oauth/oauth-required-modal.test.tsx @@ -13,6 +13,10 @@ vi.mock('@/lib/oauth/connect', () => ({ startOAuthConnectFlow: (...args: unknown[]) => mockStartOAuthConnectFlow(...args), })) +vi.mock('@/i18n/navigation', () => ({ + usePathname: () => '/workspace/ws-1/integrations', +})) + describe('OAuthRequiredModal', () => { let container: HTMLDivElement let root: Root @@ -66,7 +70,7 @@ describe('OAuthRequiredModal', () => { expect(onClose).toHaveBeenCalledTimes(1) expect(mockStartOAuthConnectFlow).toHaveBeenCalledWith({ providerId: 'alpaca-paper', - callbackURL: window.location.href, + callbackURL: '/workspace/ws-1/integrations', }) }) }) diff --git a/apps/tradinggoose/components/oauth/oauth-required-modal.tsx b/apps/tradinggoose/components/oauth/oauth-required-modal.tsx index 279fb7303..816f7692a 100644 --- a/apps/tradinggoose/components/oauth/oauth-required-modal.tsx +++ b/apps/tradinggoose/components/oauth/oauth-required-modal.tsx @@ -19,6 +19,7 @@ import { parseProvider, } from '@/lib/oauth' import { startOAuthConnectFlow } from '@/lib/oauth/connect' +import { usePathname } from '@/i18n/navigation' import { formatTemplate } from '@/i18n/utils' import { useWorkflowBlockEditorCopy } from '@/widgets/widgets/editor_workflow/copy' @@ -141,6 +142,7 @@ export function OAuthRequiredModal({ serviceIds, }: OAuthRequiredModalProps) { const copy = useWorkflowBlockEditorCopy().oauthRequiredModal + const pathname = usePathname() const { baseProvider } = parseProvider(provider) const baseProviderConfig = OAUTH_PROVIDERS[baseProvider] const resolveExplicitServiceId = (candidate?: string) => { @@ -216,7 +218,7 @@ export function OAuthRequiredModal({ await startOAuthConnectFlow({ providerId, - callbackURL: window.location.href, + callbackURL: `${pathname}${window.location.search}${window.location.hash}`, }) } catch (error) { logger.error('Error initiating OAuth flow:', { error }) diff --git a/apps/tradinggoose/components/ui/sidebar.tsx b/apps/tradinggoose/components/ui/sidebar.tsx index db2c88fdc..ba48968a4 100644 --- a/apps/tradinggoose/components/ui/sidebar.tsx +++ b/apps/tradinggoose/components/ui/sidebar.tsx @@ -367,7 +367,7 @@ const SidebarRail = React.forwardRef< if (typeof ref === 'function') { ref(node) } else if (ref) { - ; (ref as React.MutableRefObject).current = node + ;(ref as React.MutableRefObject).current = node } dragRef.current = node }, @@ -589,7 +589,7 @@ const SidebarMenuItem = React.forwardRefspan:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0', + 'peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>*:first-child]:shrink-0 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0', { variants: { variant: { @@ -692,7 +692,7 @@ const SidebarMenuAction = React.forwardRef< 'peer-data-[size=lg]/menu-button:top-2.5', 'group-data-[collapsible=icon]:hidden', showOnHover && - 'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0', + 'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0', className )} {...props} diff --git a/apps/tradinggoose/components/ui/tag-dropdown.test.tsx b/apps/tradinggoose/components/ui/tag-dropdown.test.tsx index 00aa16b85..3c0997aa5 100644 --- a/apps/tradinggoose/components/ui/tag-dropdown.test.tsx +++ b/apps/tradinggoose/components/ui/tag-dropdown.test.tsx @@ -113,10 +113,6 @@ vi.mock('@/lib/get-block', () => ({ }), })) -vi.mock('@/stores/workflows/workflow/store-client', () => ({ - DEFAULT_WORKFLOW_CHANNEL_ID: 'default', -})) - vi.mock('@/stores/workflows/registry/store', () => ({ useWorkflowRegistry: vi.fn((selector?: (state: any) => any) => { const state = { diff --git a/apps/tradinggoose/components/ui/tool-call.tsx b/apps/tradinggoose/components/ui/tool-call.tsx index d90fe2169..19c048e42 100644 --- a/apps/tradinggoose/components/ui/tool-call.tsx +++ b/apps/tradinggoose/components/ui/tool-call.tsx @@ -24,6 +24,14 @@ interface ToolCallIndicatorProps { toolNames?: string[] } +const REDACTED_VALUE = '[redacted]' + +function redactUrlQuery(value: unknown): string { + const url = String(value || '') + const queryStart = url.indexOf('?') + return queryStart === -1 ? url : `${url.slice(0, queryStart)}?${REDACTED_VALUE}` +} + // Detection State Component export function ToolCallDetection({ content }: { content: string }) { return ( @@ -48,13 +56,13 @@ export function ToolCallExecution({ toolCall, isCompact = false }: ToolCallProps >
- + {toolCall.displayName || toolCall.name} {toolCall.progress && ( {toolCall.progress} @@ -69,15 +77,14 @@ export function ToolCallExecution({ toolCall, isCompact = false }: ToolCallProps
-
+
Executing...
{toolCall.parameters && Object.keys(toolCall.parameters).length > 0 && (toolCall.name === 'make_api_request' || - toolCall.name === 'set_environment_variables' || - toolCall.name === 'set_workflow_variables') && ( + toolCall.name === 'set_environment_variables') && (
{toolCall.name === 'make_api_request' ? (
@@ -99,9 +106,9 @@ export function ToolCallExecution({ toolCall, isCompact = false }: ToolCallProps
- {String((toolCall.parameters as any).url || '') || 'URL not provided'} + {redactUrlQuery((toolCall.parameters as any).url) || 'URL not provided'}
@@ -112,10 +119,11 @@ export function ToolCallExecution({ toolCall, isCompact = false }: ToolCallProps ? (() => { const variables = (toolCall.parameters as any).variables && - typeof (toolCall.parameters as any).variables === 'object' + typeof (toolCall.parameters as any).variables === 'object' && + !Array.isArray((toolCall.parameters as any).variables) ? (toolCall.parameters as any).variables : {} - const entries = Object.entries(variables) + const names = Object.keys(variables) return (
@@ -126,82 +134,25 @@ export function ToolCallExecution({ toolCall, isCompact = false }: ToolCallProps Value
- {entries.length === 0 ? ( + {names.length === 0 ? (
No variables provided
) : (
- {entries.map(([k, v]) => ( + {names.map((name) => (
-
- {k} -
-
- - {String(v)} - +
+ {name}
-
- ))} -
- )} -
- ) - })() - : null} - - {toolCall.name === 'set_workflow_variables' - ? (() => { - const ops = Array.isArray((toolCall.parameters as any).operations) - ? ((toolCall.parameters as any).operations as any[]) - : [] - return ( -
-
-
- Name -
-
- Type -
-
- Value -
-
- {ops.length === 0 ? ( -
- No operations provided -
- ) : ( -
- {ops.map((op, idx) => ( -
- - {String(op.name || '')} - -
-
- - {String(op.type || '')} + + {REDACTED_VALUE}
-
- {op.value !== undefined ? ( - - {String(op.value)} - - ) : ( - — - )} -
))}
@@ -307,38 +258,6 @@ export function ToolCallCompletion({ toolCall, isCompact = false }: ToolCallProp
- {toolCall.parameters && - Object.keys(toolCall.parameters).length > 0 && - (toolCall.name === 'make_api_request' || - toolCall.name === 'set_environment_variables') && ( -
-
- Parameters: -
-
- {JSON.stringify(toolCall.parameters, null, 2)} -
-
- )} - {toolCall.error && (
diff --git a/apps/tradinggoose/contexts/socket-context.tsx b/apps/tradinggoose/contexts/socket-context.tsx index 07735a8d2..a8573ccba 100644 --- a/apps/tradinggoose/contexts/socket-context.tsx +++ b/apps/tradinggoose/contexts/socket-context.tsx @@ -5,6 +5,7 @@ import { io, type Socket } from 'socket.io-client' import { handleAuthError } from '@/lib/auth/auth-error-handler' import { getEnv } from '@/lib/env' import { createLogger } from '@/lib/logs/console/logger' +import { usePathname } from '@/i18n/navigation' const logger = createLogger('SocketContext') const isSocketAuthError = (message: string) => @@ -17,11 +18,12 @@ const logSocketIssue = ( details: { message: string type?: string - } + }, + callbackPathname: string ) => { if (isSocketAuthError(details.message)) { - logger.warn(event, details) - void handleAuthError('socket-auth') + logger.warn(event, { ...details, callbackPathname }) + void handleAuthError('socket-auth', callbackPathname) } else { logger.error(event, details) } @@ -133,9 +135,12 @@ const getGlobalSocketRegistry = (): Map => { } export function SocketProvider({ children, user }: SocketProviderProps) { + const pathname = usePathname() const [socket, setSocket] = useState(null) const [isConnected, setIsConnected] = useState(false) const [isConnecting, setIsConnecting] = useState(false) + const callbackPathnameRef = useRef(pathname) + callbackPathnameRef.current = pathname // Track socket in a ref so the cleanup closure always sees the latest value, // avoiding the race where `socket` state is still null during fast unmount. @@ -207,10 +212,14 @@ export function SocketProvider({ children, user }: SocketProviderProps) { const onConnectError = (error: any) => { setIsConnected(false) setIsConnecting(false) - logSocketIssue('Socket connection error:', { - message: error instanceof Error ? error.message : String(error), - type: error?.type, - }) + logSocketIssue( + 'Socket connection error:', + { + message: error instanceof Error ? error.message : String(error), + type: error?.type, + }, + callbackPathnameRef.current + ) } socketInstance.on('connect', onConnect) @@ -247,9 +256,13 @@ export function SocketProvider({ children, user }: SocketProviderProps) { setupSocketCleanup = setupSocket(socket) }) .catch((err) => { - logSocketIssue('Shared socket initialization failed', { - message: err instanceof Error ? err.message : String(err), - }) + logSocketIssue( + 'Shared socket initialization failed', + { + message: err instanceof Error ? err.message : String(err), + }, + callbackPathnameRef.current + ) if (!disposed) setIsConnecting(false) registry.delete(user.id) // Allow retry }) @@ -284,9 +297,13 @@ export function SocketProvider({ children, user }: SocketProviderProps) { const freshToken = await generateSocketToken() cb({ token: freshToken }) } catch (error) { - logSocketIssue('Failed to generate fresh token for connection:', { - message: error instanceof Error ? error.message : String(error), - }) + logSocketIssue( + 'Failed to generate fresh token for connection:', + { + message: error instanceof Error ? error.message : String(error), + }, + callbackPathnameRef.current + ) cb({ token: null }) } }, @@ -315,9 +332,13 @@ export function SocketProvider({ children, user }: SocketProviderProps) { setupSocketCleanup = setupSocket(socket) }) .catch((err) => { - logSocketIssue('Failed to initialize socket:', { - message: err instanceof Error ? err.message : String(err), - }) + logSocketIssue( + 'Failed to initialize socket:', + { + message: err instanceof Error ? err.message : String(err), + }, + callbackPathnameRef.current + ) if (!disposed) setIsConnecting(false) registry.delete(user.id) }) diff --git a/apps/tradinggoose/executor/__test-utils__/mock-dependencies.ts b/apps/tradinggoose/executor/__test-utils__/mock-dependencies.ts index a53e5e4f9..82b0509c9 100644 --- a/apps/tradinggoose/executor/__test-utils__/mock-dependencies.ts +++ b/apps/tradinggoose/executor/__test-utils__/mock-dependencies.ts @@ -20,7 +20,6 @@ vi.mock('@/blocks/index', () => ({ // Tools vi.mock('@/tools/utils', () => ({ getTool: vi.fn(), - getToolAsync: vi.fn(), validateToolRequest: vi.fn(), // Keep for backward compatibility formatRequestParams: vi.fn(), transformTable: vi.fn(), diff --git a/apps/tradinggoose/executor/__test-utils__/test-executor.ts b/apps/tradinggoose/executor/__test-utils__/test-executor.ts index 281865b29..caded0eb6 100644 --- a/apps/tradinggoose/executor/__test-utils__/test-executor.ts +++ b/apps/tradinggoose/executor/__test-utils__/test-executor.ts @@ -16,11 +16,11 @@ export class TestExecutor extends Executor { /** * Override the execute method to return a pre-defined result for testing */ - async execute(workflowId: string): Promise { + async execute(workflowId: string, triggerBlockId: string): Promise { try { // Call validateWorkflow to ensure we validate the workflow // even though we're not actually executing it - ;(this as any).validateWorkflow() + ;(this as any).validateWorkflow(triggerBlockId) // Return a successful result return { diff --git a/apps/tradinggoose/executor/handlers/agent/agent-handler.test.ts b/apps/tradinggoose/executor/handlers/agent/agent-handler.test.ts index 41adcd375..584b0cfb4 100644 --- a/apps/tradinggoose/executor/handlers/agent/agent-handler.test.ts +++ b/apps/tradinggoose/executor/handlers/agent/agent-handler.test.ts @@ -31,6 +31,7 @@ vi.mock('@/blocks', () => ({ vi.mock('@/tools', () => ({ executeTool: vi.fn(), + getToolAsync: vi.fn(), })) vi.mock('@/executor/handlers/agent/skills-resolver', () => ({ @@ -188,6 +189,7 @@ describe('AgentBlockHandler', () => { } mockGetProviderFromModel.mockReturnValue('openai') + mockContext.isDeployedContext = false const expectedOutput = { content: 'Mocked response content', @@ -203,6 +205,8 @@ describe('AgentBlockHandler', () => { expect(mockGetProviderFromModel).toHaveBeenCalledWith('gpt-4o') expect(mockFetch).toHaveBeenCalledWith(expect.any(String), expect.any(Object)) + const [, init] = mockFetch.mock.calls.find(([url]) => String(url).includes('/api/providers'))! + expect(JSON.parse(String(init.body)).isDeployedContext).toBe(false) expect(result).toEqual(expectedOutput) }) @@ -210,6 +214,7 @@ describe('AgentBlockHandler', () => { mockContext.workspaceId = 'workspace-123' mockResolveSkillMetadata.mockResolvedValueOnce([ { + id: 'skill-1', name: 'market-research', description: 'Research the market before acting', }, @@ -309,6 +314,29 @@ describe('AgentBlockHandler', () => { expect(systemMessage?.content).toContain('tradinggoose_internal_load_skill_2') }) + it('executes without a skill loader when selected skills are unavailable', async () => { + mockResolveSkillMetadata.mockResolvedValueOnce([]) + + await handler.execute( + mockBlock, + { + model: 'gpt-4o', + userPrompt: 'Use the selected skill.', + apiKey: 'test-api-key', + skills: [{ skillId: 'deleted-skill' }], + }, + mockContext + ) + + const [, init] = mockFetch.mock.calls.find(([url]) => String(url).includes('/api/providers'))! + const providerRequest = JSON.parse(String(init.body)) + + expect(providerRequest.tools).toEqual([]) + expect(providerRequest.messages).toEqual([ + { role: 'user', content: 'Use the selected skill.' }, + ]) + }) + it('should preserve executeFunction for custom tools with different usageControl settings', async () => { let capturedTools: any[] = [] @@ -341,11 +369,11 @@ describe('AgentBlockHandler', () => { tokens: { prompt: 10, completion: 20, total: 30 }, toolCalls: [ { - name: 'auto_tool', + name: 'custom_auto_tool', arguments: { input: 'test input for auto tool' }, }, { - name: 'force_tool', + name: 'custom_force_tool', arguments: { input: 'test input for force tool' }, }, ], @@ -362,11 +390,11 @@ describe('AgentBlockHandler', () => { { type: 'custom-tool', title: 'Auto Tool', + toolId: 'custom_auto_tool', code: 'return { result: "auto tool executed", input }', timeout: 1000, schema: { function: { - name: 'auto_tool', description: 'Custom tool with auto usage control', parameters: { type: 'object', @@ -381,11 +409,11 @@ describe('AgentBlockHandler', () => { { type: 'custom-tool', title: 'Force Tool', + toolId: 'custom_force_tool', code: 'return { result: "force tool executed", input }', timeout: 1000, schema: { function: { - name: 'force_tool', description: 'Custom tool with forced usage control', parameters: { type: 'object', @@ -400,11 +428,11 @@ describe('AgentBlockHandler', () => { { type: 'custom-tool', title: 'None Tool', + toolId: 'custom_none_tool', code: 'return { result: "none tool executed", input }', timeout: 1000, schema: { function: { - name: 'none_tool', description: 'Custom tool that should be filtered out', parameters: { type: 'object', @@ -421,15 +449,17 @@ describe('AgentBlockHandler', () => { mockGetProviderFromModel.mockReturnValue('openai') - await handler.execute(mockBlock, inputs, mockContext) + const output = (await handler.execute(mockBlock, inputs, mockContext)) as { + toolCalls: { list: Array<{ id?: string; name: string }> } + } expect(Promise.all).toHaveBeenCalled() expect(capturedTools.length).toBe(2) - const autoTool = capturedTools.find((t) => t.name === 'auto_tool') - const forceTool = capturedTools.find((t) => t.name === 'force_tool') - const noneTool = capturedTools.find((t) => t.name === 'none_tool') + const autoTool = capturedTools.find((t) => t.id === 'custom_auto_tool') + const forceTool = capturedTools.find((t) => t.id === 'custom_force_tool') + const noneTool = capturedTools.find((t) => t.id === 'custom_none_tool') expect(autoTool).toBeDefined() expect(forceTool).toBeDefined() @@ -468,6 +498,10 @@ describe('AgentBlockHandler', () => { const requestBody = JSON.parse(fetchCall[1].body) expect(requestBody.tools.length).toBe(2) + expect(output.toolCalls.list).toMatchObject([ + { id: 'custom_auto_tool', name: 'Auto Tool' }, + { id: 'custom_force_tool', name: 'Force Tool' }, + ]) }) it('should filter out tools with usageControl set to "none"', async () => { @@ -565,9 +599,9 @@ describe('AgentBlockHandler', () => { { type: 'custom-tool', title: 'Custom Tool - Auto', + toolId: 'custom_tool_auto', schema: { function: { - name: 'custom_tool_auto', description: 'A custom tool with auto usage control', parameters: { type: 'object', @@ -580,9 +614,9 @@ describe('AgentBlockHandler', () => { { type: 'custom-tool', title: 'Custom Tool - Force', + toolId: 'custom_tool_force', schema: { function: { - name: 'custom_tool_force', description: 'A custom tool with forced usage', parameters: { type: 'object', @@ -595,9 +629,9 @@ describe('AgentBlockHandler', () => { { type: 'custom-tool', title: 'Custom Tool - None', + toolId: 'custom_tool_none', schema: { function: { - name: 'custom_tool_none', description: 'A custom tool that should not be used', parameters: { type: 'object', @@ -619,14 +653,16 @@ describe('AgentBlockHandler', () => { expect(requestBody.tools.length).toBe(2) - const toolNames = requestBody.tools.map((t: any) => t.name) - expect(toolNames).toContain('custom_tool_auto') - expect(toolNames).toContain('custom_tool_force') - expect(toolNames).not.toContain('custom_tool_none') + const toolIds = requestBody.tools.map((t: any) => t.id) + expect(toolIds).toContain('custom_tool_auto') + expect(toolIds).toContain('custom_tool_force') + expect(toolIds).not.toContain('custom_tool_none') - const autoTool = requestBody.tools.find((t: any) => t.name === 'custom_tool_auto') - const forceTool = requestBody.tools.find((t: any) => t.name === 'custom_tool_force') + const autoTool = requestBody.tools.find((t: any) => t.id === 'custom_tool_auto') + const forceTool = requestBody.tools.find((t: any) => t.id === 'custom_tool_force') + expect(autoTool.name).toBe('Custom Tool - Auto') + expect(forceTool.name).toBe('Custom Tool - Force') expect(autoTool.usageControl).toBe('auto') expect(forceTool.usageControl).toBe('force') }) @@ -693,6 +729,83 @@ describe('AgentBlockHandler', () => { expect(result).toEqual(expectedOutput) }) + it('skips selected tools that cannot be hydrated', async () => { + const inputs = { + model: 'gpt-4o', + userPrompt: 'Analyze this data.', + apiKey: 'test-api-key', + tools: [ + { + id: 'block_tool_1', + title: 'Data Analysis Tool', + operation: 'analyze', + }, + { + id: 'missing_tool', + title: 'Missing Tool', + operation: 'analyze', + }, + ], + } + + mockTransformBlockTool.mockImplementation((tool: any) => + tool.id === 'block_tool_1' + ? { + id: 'transformed_block_tool_1', + name: 'block_tool_1_analyze', + description: 'Transformed tool', + parameters: { type: 'object', properties: {} }, + } + : null + ) + mockGetProviderFromModel.mockReturnValue('openai') + + await handler.execute(mockBlock, inputs, mockContext) + + expect(mockTransformBlockTool).toHaveBeenCalledWith( + inputs.tools[0], + expect.objectContaining({ selectedOperation: 'analyze' }) + ) + expect(mockTransformBlockTool).toHaveBeenCalledWith( + inputs.tools[1], + expect.objectContaining({ selectedOperation: 'analyze' }) + ) + const [, init] = mockFetch.mock.calls.find(([url]) => String(url).includes('/api/providers'))! + const providerRequest = JSON.parse(String(init.body)) + + expect(providerRequest.tools).toHaveLength(1) + expect(providerRequest.tools[0].id).toBe('transformed_block_tool_1') + }) + + it('skips unavailable MCP tool selections before provider startup', async () => { + const inputs = { + model: 'gpt-4o', + userPrompt: 'Use the MCP tool if available.', + apiKey: 'test-api-key', + tools: [ + { + type: 'mcp', + title: 'Read Files', + params: { + serverId: 'deleted-server', + toolName: 'read_file', + }, + }, + ], + } + + mockGetProviderFromModel.mockReturnValue('openai') + + await handler.execute(mockBlock, inputs, mockContext) + const calledUrls = mockFetch.mock.calls.map(([url]) => String(url)) + const [, init] = mockFetch.mock.calls.find(([url]) => String(url).includes('/api/providers'))! + const providerRequest = JSON.parse(String(init.body)) + + expect(calledUrls.some((url) => url.includes('/api/mcp/tools/discover'))).toBe(true) + expect(calledUrls.some((url) => url.includes('/api/providers'))).toBe(true) + expect(providerRequest.tools).toEqual([]) + }) + it('should execute with custom tools (schema only and with code)', async () => { const inputs = { model: 'gpt-4o', @@ -702,9 +815,9 @@ describe('AgentBlockHandler', () => { { type: 'custom-tool', title: 'Custom Schema Tool', + toolId: 'custom_schema_tool', schema: { function: { - name: 'custom_schema_tool', description: 'A tool defined only by schema', parameters: { type: 'object', @@ -718,11 +831,11 @@ describe('AgentBlockHandler', () => { { type: 'custom-tool', title: 'Custom Code Tool', + toolId: 'custom_code_tool', code: 'return { result: input * 2 }', timeout: 1000, schema: { function: { - name: 'custom_code_tool', description: 'A tool with code execution', parameters: { type: 'object', @@ -1466,341 +1579,5 @@ describe('AgentBlockHandler', () => { expect(requestBody.provider).toBe('openai') expect(requestBody.model).toBe('gpt-5') }) - - it('should handle MCP tools in agent execution', async () => { - mockExecuteTool.mockImplementation((toolId, params, skipPostProcess, context) => { - if (toolId.startsWith('mcp-')) { - return Promise.resolve({ - success: true, - output: { - content: [ - { - type: 'text', - text: `MCP tool ${toolId} executed with params: ${JSON.stringify(params)}`, - }, - ], - }, - }) - } - return Promise.resolve({ success: false, error: 'Unknown tool' }) - }) - - mockFetch.mockImplementationOnce(() => { - return Promise.resolve({ - ok: true, - headers: { - get: (name: string) => { - if (name === 'Content-Type') return 'application/json' - if (name === 'X-Execution-Data') return null - return null - }, - }, - json: () => - Promise.resolve({ - content: 'I will use MCP tools to help you.', - model: 'gpt-4o', - tokens: { prompt: 15, completion: 25, total: 40 }, - toolCalls: [ - { - name: 'mcp-server1-list_files', - arguments: { path: '/tmp' }, - result: { - success: true, - output: { content: [{ type: 'text', text: 'Files listed' }] }, - }, - }, - { - name: 'mcp-server2-search', - arguments: { query: 'test', limit: 5 }, - result: { - success: true, - output: { content: [{ type: 'text', text: 'Search results' }] }, - }, - }, - ], - timing: { total: 150 }, - }), - }) - }) - - const inputs = { - model: 'gpt-4o', - userPrompt: 'List files and search for test data', - apiKey: 'test-api-key', - tools: [ - { - type: 'mcp', - title: 'List Files', - schema: { - function: { - name: 'mcp-server1-list_files', - description: 'List files in directory', - parameters: { - type: 'object', - properties: { - path: { type: 'string', description: 'Directory path' }, - }, - }, - }, - }, - usageControl: 'auto' as const, - }, - { - type: 'mcp', - title: 'Search', - schema: { - function: { - name: 'mcp-server2-search', - description: 'Search for data', - parameters: { - type: 'object', - properties: { - query: { type: 'string', description: 'Search query' }, - limit: { type: 'number', description: 'Result limit' }, - }, - }, - }, - }, - usageControl: 'auto' as const, - }, - ], - } - - const mcpContext = { - ...mockContext, - workspaceId: 'test-workspace-123', - } - - mockGetProviderFromModel.mockReturnValue('openai') - - const result = await handler.execute(mockBlock, inputs, mcpContext) - - expect((result as any).content).toBe('I will use MCP tools to help you.') - expect((result as any).toolCalls.count).toBe(2) - expect((result as any).toolCalls.list).toHaveLength(2) - - expect((result as any).toolCalls.list[0].name).toBe('mcp-server1-list_files') - expect((result as any).toolCalls.list[0].result.success).toBe(true) - expect((result as any).toolCalls.list[1].name).toBe('mcp-server2-search') - expect((result as any).toolCalls.list[1].result.success).toBe(true) - }) - - it('should handle MCP tool execution errors', async () => { - mockExecuteTool.mockImplementation((toolId, params) => { - if (toolId === 'mcp-server1-failing_tool') { - return Promise.resolve({ - success: false, - error: 'MCP server connection failed', - }) - } - return Promise.resolve({ success: false, error: 'Unknown tool' }) - }) - - mockFetch.mockImplementationOnce(() => { - return Promise.resolve({ - ok: true, - headers: { - get: (name: string) => { - if (name === 'Content-Type') return 'application/json' - if (name === 'X-Execution-Data') return null - return null - }, - }, - json: () => - Promise.resolve({ - content: 'Let me try to use this tool.', - model: 'gpt-4o', - tokens: { prompt: 10, completion: 15, total: 25 }, - toolCalls: [ - { - name: 'mcp-server1-failing_tool', - arguments: { param: 'value' }, - result: { - success: false, - error: 'MCP server connection failed', - }, - }, - ], - timing: { total: 100 }, - }), - }) - }) - - const inputs = { - model: 'gpt-4o', - userPrompt: 'Try to use the failing tool', - apiKey: 'test-api-key', - tools: [ - { - type: 'mcp', - title: 'Failing Tool', - schema: { - function: { - name: 'mcp-server1-failing_tool', - description: 'A tool that will fail', - parameters: { - type: 'object', - properties: { - param: { type: 'string' }, - }, - }, - }, - }, - usageControl: 'auto' as const, - }, - ], - } - - const mcpContext = { - ...mockContext, - workspaceId: 'test-workspace-123', - } - - mockGetProviderFromModel.mockReturnValue('openai') - - const result = await handler.execute(mockBlock, inputs, mcpContext) - - expect((result as any).content).toBe('Let me try to use this tool.') - expect((result as any).toolCalls.count).toBe(1) - expect((result as any).toolCalls.list[0].result.success).toBe(false) - expect((result as any).toolCalls.list[0].result.error).toBe('MCP server connection failed') - }) - - it('should transform MCP tools correctly for agent execution', async () => { - const inputs = { - model: 'gpt-4o', - userPrompt: 'Use MCP tools to help me', - apiKey: 'test-api-key', - tools: [ - { - type: 'mcp', - title: 'Read File', - schema: { - function: { - name: 'mcp-filesystem-read_file', - description: 'Read file from filesystem', - parameters: { type: 'object', properties: {} }, - }, - }, - usageControl: 'auto' as const, - }, - { - type: 'mcp', - title: 'Web Search', - schema: { - function: { - name: 'mcp-web-search', - description: 'Search the web', - parameters: { type: 'object', properties: {} }, - }, - }, - usageControl: 'force' as const, - }, - ], - } - - mockGetProviderFromModel.mockReturnValue('openai') - - mockFetch.mockImplementationOnce(() => { - return Promise.resolve({ - ok: true, - headers: { - get: (name: string) => { - if (name === 'Content-Type') return 'application/json' - if (name === 'X-Execution-Data') return null - return null - }, - }, - json: () => - Promise.resolve({ - content: 'Used MCP tools successfully', - model: 'gpt-4o', - tokens: { prompt: 20, completion: 30, total: 50 }, - toolCalls: [], - timing: { total: 200 }, - }), - }) - }) - - mockTransformBlockTool.mockImplementation((tool: any) => ({ - id: tool.schema?.function?.name || `mcp-${tool.title.toLowerCase().replace(' ', '-')}`, - name: tool.schema?.function?.name || tool.title, - description: tool.schema?.function?.description || `MCP tool: ${tool.title}`, - parameters: tool.schema?.function?.parameters || { type: 'object', properties: {} }, - usageControl: tool.usageControl, - })) - - const result = await handler.execute(mockBlock, inputs, mockContext) - - // Verify that the agent executed successfully with MCP tools - expect(result).toBeDefined() - expect(mockFetch).toHaveBeenCalled() - - // Verify the agent returns the expected response format - expect((result as any).content).toBe('Used MCP tools successfully') - expect((result as any).model).toBe('gpt-4o') - }) - - it('should provide workspaceId context for MCP tool execution', async () => { - let capturedContext: any - mockExecuteTool.mockImplementation((toolId, params, skipPostProcess, context) => { - capturedContext = context - if (toolId.startsWith('mcp-')) { - return Promise.resolve({ - success: true, - output: { content: [{ type: 'text', text: 'Success' }] }, - }) - } - return Promise.resolve({ success: false, error: 'Unknown tool' }) - }) - - mockFetch.mockImplementationOnce(() => { - return Promise.resolve({ - ok: true, - headers: { - get: (name: string) => (name === 'Content-Type' ? 'application/json' : null), - }, - json: () => - Promise.resolve({ - content: 'Using MCP tool', - model: 'gpt-4o', - tokens: { prompt: 10, completion: 10, total: 20 }, - toolCalls: [{ name: 'mcp-test-tool', arguments: {} }], - timing: { total: 50 }, - }), - }) - }) - - const inputs = { - model: 'gpt-4o', - userPrompt: 'Test MCP context', - apiKey: 'test-api-key', - tools: [ - { - type: 'mcp', - title: 'Test Tool', - schema: { - function: { - name: 'mcp-test-tool', - description: 'Test MCP tool', - parameters: { type: 'object', properties: {} }, - }, - }, - usageControl: 'auto' as const, - }, - ], - } - - const contextWithWorkspace = { - ...mockContext, - workspaceId: 'test-workspace-456', - } - - mockGetProviderFromModel.mockReturnValue('openai') - - await handler.execute(mockBlock, inputs, contextWithWorkspace) - - expect(contextWithWorkspace.workspaceId).toBe('test-workspace-456') - }) }) }) diff --git a/apps/tradinggoose/executor/handlers/agent/agent-handler.ts b/apps/tradinggoose/executor/handlers/agent/agent-handler.ts index bf1e4781d..2b5cdae76 100644 --- a/apps/tradinggoose/executor/handlers/agent/agent-handler.ts +++ b/apps/tradinggoose/executor/handlers/agent/agent-handler.ts @@ -1,3 +1,7 @@ +import { + buildCustomToolModelDescription, + getCustomToolEntityIdFromRuntimeId, +} from '@/lib/custom-tools/schema' import { createLogger } from '@/lib/logs/console/logger' import { createMcpToolId } from '@/lib/mcp/utils' import { getBaseUrl } from '@/lib/urls/utils' @@ -14,9 +18,9 @@ import { getBlockToolExecutionId } from '@/executor/handlers/tool-execution-cont import type { BlockHandler, ExecutionContext, StreamingExecution } from '@/executor/types' import { getProviderFromModel, transformBlockTool } from '@/providers/ai/utils' import type { SerializedBlock } from '@/serializer/types' -import { executeTool } from '@/tools' +import { executeTool, getToolAsync } from '@/tools' import { createLLMToolSchema } from '@/tools/params' -import { getTool, getToolAsync } from '@/tools/utils' +import { getTool } from '@/tools/utils' import { buildLoadSkillTool, buildSkillsSystemPromptSection, @@ -29,7 +33,6 @@ const logger = createLogger('AgentBlockHandler') const DEFAULT_MODEL = 'gpt-4o' const DEFAULT_FUNCTION_TIMEOUT = 600000 const REQUEST_TIMEOUT = 120000 -const CUSTOM_TOOL_PREFIX = 'custom_' /** * Helper function to collect runtime block outputs and name mappings @@ -82,7 +85,11 @@ export class AgentBlockHandler implements BlockHandler { : [] const skillMetadata = skillInputs.length > 0 && context.workspaceId - ? await resolveSkillMetadata(skillInputs, context.workspaceId) + ? await resolveSkillMetadata( + skillInputs, + context.workspaceId, + context.isDeployedContext !== false + ) : [] const skillLoaderToolId = skillMetadata.length > 0 @@ -94,12 +101,7 @@ export class AgentBlockHandler implements BlockHandler { : null if (skillMetadata.length > 0 && skillLoaderToolId) { - formattedTools.push( - buildLoadSkillTool( - skillLoaderToolId, - skillMetadata.map((skill) => skill.name) - ) - ) + formattedTools.push(buildLoadSkillTool(skillLoaderToolId, skillMetadata)) } const streamingConfig = this.getStreamingConfig(block, context) @@ -199,19 +201,18 @@ export class AgentBlockHandler implements BlockHandler { if (tool.type === 'mcp') { return await this.createMcpTool(tool, context) } - return this.transformBlockTool(tool, context) + return await this.transformBlockTool(tool, context) } catch (error) { - logger.error(`[AgentHandler] Error creating tool:`, { tool, error }) + logger.warn( + `Skipping unavailable agent tool ${tool.title || tool.toolId || tool.type}:`, + error + ) return null } }) ) - const filteredTools = tools.filter( - (tool): tool is NonNullable => tool !== null && tool !== undefined - ) - - return filteredTools + return tools.filter((tool): tool is NonNullable => tool !== null) } private async createCustomTool(tool: ToolInput, context: ExecutionContext): Promise { @@ -221,11 +222,15 @@ export class AgentBlockHandler implements BlockHandler { const filteredSchema = filterSchemaForLLM(tool.schema.function.parameters, userProvidedParams) - const toolId = `${CUSTOM_TOOL_PREFIX}${tool.title}` + const toolId = tool.toolId + getCustomToolEntityIdFromRuntimeId(toolId) const base: any = { id: toolId, - name: tool.schema.function.name, - description: tool.schema.function.description || '', + name: tool.title?.trim(), + description: buildCustomToolModelDescription({ + title: tool.title, + description: tool.schema.function.description, + }), params: userProvidedParams, parameters: { ...filteredSchema, @@ -272,131 +277,121 @@ export class AgentBlockHandler implements BlockHandler { const { serverId, toolName, ...userProvidedParams } = tool.params || {} if (!serverId || !toolName) { - logger.error('MCP tool missing required parameters:', { serverId, toolName }) - return null + throw new Error('MCP tool selection is missing serverId or toolName') } - try { - const headers: Record = { 'Content-Type': 'application/json' } - - if (typeof window === 'undefined') { - try { - const { generateInternalToken } = await import('@/lib/auth/internal') - const internalToken = await generateInternalToken(context.userId) - headers.Authorization = `Bearer ${internalToken}` - } catch (error) { - logger.error(`Failed to generate internal token for MCP tool discovery:`, error) - } - } + const headers: Record = { 'Content-Type': 'application/json' } - const url = new URL('/api/mcp/tools/discover', getBaseUrl()) - url.searchParams.set('serverId', serverId) - if (context.workspaceId) { - url.searchParams.set('workspaceId', context.workspaceId) - } else { - throw new Error('workspaceId is required for MCP tool discovery') - } - if (context.workflowId) { - url.searchParams.set('workflowId', context.workflowId) - } else { - throw new Error('workflowId is required for internal JWT authentication') + if (typeof window === 'undefined') { + try { + const { generateInternalToken } = await import('@/lib/auth/internal') + const internalToken = await generateInternalToken(context.userId) + headers.Authorization = `Bearer ${internalToken}` + } catch (error) { + logger.error(`Failed to generate internal token for MCP tool discovery:`, error) } + } - const response = await fetch(url.toString(), { - method: 'GET', - headers, - }) - if (!response.ok) { - const errorText = await response.text().catch(() => '') - logger.warn( - `Failed to discover tools from server ${serverId} (status ${response.status})`, - { errorText } - ) - return null - } + const url = new URL('/api/mcp/tools/discover', getBaseUrl()) + url.searchParams.set('serverId', serverId) + if (context.workspaceId) { + url.searchParams.set('workspaceId', context.workspaceId) + } else { + throw new Error('workspaceId is required for MCP tool discovery') + } + if (context.workflowId) { + url.searchParams.set('workflowId', context.workflowId) + } else { + throw new Error('workflowId is required for internal JWT authentication') + } + url.searchParams.set('isDeployedContext', String(context.isDeployedContext !== false)) - const data = await response.json() - if (!data.success) { - logger.warn(`MCP discovery returned unsuccessful for ${serverId}`, { - error: data.error, - }) - return null - } + const response = await fetch(url.toString(), { + method: 'GET', + headers, + }) + if (!response.ok) { + const errorText = await response.text().catch(() => '') + throw new Error( + `Failed to discover tools from MCP server ${serverId} (status ${response.status})${errorText ? `: ${errorText}` : ''}` + ) + } - const mcpTool = data.data.tools.find((t: any) => t.name === toolName) - if (!mcpTool) { - logger.warn(`MCP tool ${toolName} not found on server ${serverId}`) - return null - } + const data = await response.json() + if (!data.success) { + throw new Error(data.error || `MCP tool discovery failed for server ${serverId}`) + } - const toolId = createMcpToolId(serverId, toolName) + const mcpTool = data.data.tools.find((t: any) => t.name === toolName) + if (!mcpTool) { + throw new Error(`MCP tool ${toolName} not found on server ${serverId}`) + } - const { filterSchemaForLLM } = await import('@/tools/params') - const filteredSchema = filterSchemaForLLM( - mcpTool.inputSchema || { type: 'object', properties: {} }, - userProvidedParams - ) + const toolId = createMcpToolId(serverId, toolName) - return { - id: toolId, - name: toolName, - description: mcpTool.description || `MCP tool ${toolName} from ${mcpTool.serverName}`, - parameters: filteredSchema, - params: userProvidedParams, - usageControl: tool.usageControl || 'auto', - executeFunction: async (callParams: Record) => { - logger.info(`Executing MCP tool ${toolName} on server ${serverId}`) - - const headers: Record = { 'Content-Type': 'application/json' } - - if (typeof window === 'undefined') { - try { - const { generateInternalToken } = await import('@/lib/auth/internal') - const internalToken = await generateInternalToken(context.userId) - headers.Authorization = `Bearer ${internalToken}` - } catch (error) { - logger.error(`Failed to generate internal token for MCP tool ${toolName}:`, error) - } - } + const { filterSchemaForLLM } = await import('@/tools/params') + const filteredSchema = filterSchemaForLLM( + mcpTool.inputSchema || { type: 'object', properties: {} }, + userProvidedParams + ) - const execResponse = await fetch(`${getBaseUrl()}/api/mcp/tools/execute`, { - method: 'POST', - headers, - body: JSON.stringify({ - serverId, - toolName, - arguments: callParams, - workspaceId: context.workspaceId, - workflowId: context.workflowId, - }), - }) - - if (!execResponse.ok) { - throw new Error( - `MCP tool execution failed: ${execResponse.status} ${execResponse.statusText}` - ) - } + return { + id: toolId, + name: toolName, + description: mcpTool.description || `MCP tool ${toolName} from ${mcpTool.serverName}`, + parameters: filteredSchema, + params: userProvidedParams, + usageControl: tool.usageControl || 'auto', + executeFunction: async (callParams: Record) => { + logger.info(`Executing MCP tool ${toolName} on server ${serverId}`) - const result = await execResponse.json() - if (!result.success) { - throw new Error(result.error || 'MCP tool execution failed') - } + const headers: Record = { 'Content-Type': 'application/json' } - return { - success: true, - output: result.data.output || {}, - metadata: { - source: 'mcp', - serverId, - serverName: mcpTool.serverName, - toolName, - }, + if (typeof window === 'undefined') { + try { + const { generateInternalToken } = await import('@/lib/auth/internal') + const internalToken = await generateInternalToken(context.userId) + headers.Authorization = `Bearer ${internalToken}` + } catch (error) { + logger.error(`Failed to generate internal token for MCP tool ${toolName}:`, error) } - }, - } - } catch (error) { - logger.warn(`Failed to create MCP tool ${toolName} from server ${serverId}:`, error) - return null + } + + const execResponse = await fetch(`${getBaseUrl()}/api/mcp/tools/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + serverId, + toolName, + arguments: callParams, + workspaceId: context.workspaceId, + workflowId: context.workflowId, + isDeployedContext: context.isDeployedContext !== false, + }), + }) + + if (!execResponse.ok) { + throw new Error( + `MCP tool execution failed: ${execResponse.status} ${execResponse.statusText}` + ) + } + + const result = await execResponse.json() + if (!result.success) { + throw new Error(result.error || 'MCP tool execution failed') + } + + return { + success: true, + output: result.data.output || {}, + metadata: { + source: 'mcp', + serverId, + serverName: mcpTool.serverName, + toolName, + }, + } + }, } } @@ -405,14 +400,20 @@ export class AgentBlockHandler implements BlockHandler { selectedOperation: tool.operation, getAllBlocks, getToolAsync: (toolId: string) => - getToolAsync(toolId, context.workflowId, context.workspaceId, context.userId), + getToolAsync( + toolId, + context.workflowId, + context.workspaceId, + context.isDeployedContext !== false + ), getTool, createLLMToolSchema, }) - if (transformedTool) { - transformedTool.usageControl = tool.usageControl || 'auto' + if (!transformedTool) { + throw new Error(`Agent tool ${tool.title || tool.toolId || tool.type} could not be resolved`) } + transformedTool.usageControl = tool.usageControl || 'auto' return transformedTool } @@ -436,7 +437,7 @@ export class AgentBlockHandler implements BlockHandler { private buildMessages( inputs: AgentInputs, - skillMetadata: Array<{ name: string; description: string }> = [], + skillMetadata: Array<{ id: string; name: string; description: string }> = [], skillLoaderToolId?: string | null ): Message[] | undefined { if ( @@ -606,6 +607,7 @@ export class AgentBlockHandler implements BlockHandler { workflowVariables: context.workflowVariables || {}, blockData, blockNameMapping, + isDeployedContext: context.isDeployedContext !== false, reasoningEffort: inputs.reasoningEffort, verbosity: inputs.verbosity, } @@ -695,7 +697,11 @@ export class AgentBlockHandler implements BlockHandler { result ) - return this.processProviderResponse(result, block, responseFormat) + return this.processProviderResponse( + this.withToolCallMetadata(result, providerRequest.tools), + block, + responseFormat + ) } private async executeBrowserSide( @@ -755,17 +761,22 @@ export class AgentBlockHandler implements BlockHandler { if (contentType?.includes('text/event-stream')) { // Handle streaming response logger.info('Received streaming response') - return this.handleStreamingResponse(response, block) + return this.handleStreamingResponse(response, block, providerRequest.tools) } // Handle regular JSON response const result = await response.json() - return this.processProviderResponse(result, block, responseFormat) + return this.processProviderResponse( + this.withToolCallMetadata(result, providerRequest.tools), + block, + responseFormat + ) } private async handleStreamingResponse( response: Response, - block: SerializedBlock + block: SerializedBlock, + tools: any[] ): Promise { // Check if we have execution data in headers (from StreamingExecution) const executionDataHeader = response.headers.get('X-Execution-Data') @@ -780,7 +791,7 @@ export class AgentBlockHandler implements BlockHandler { stream: response.body!, execution: { success: executionData.success, - output: executionData.output || {}, + output: this.withToolCallMetadata({ output: executionData.output }, tools).output || {}, error: executionData.error, logs: [], // Logs are stripped from headers, will be populated by executor metadata: executionData.metadata || { @@ -985,6 +996,28 @@ export class AgentBlockHandler implements BlockHandler { } } + private withToolCallMetadata(result: any, tools: any[] = []) { + const toolNames = new Map() + for (const tool of tools) { + if (typeof tool?.id === 'string' && typeof tool.name === 'string') { + toolNames.set(tool.id, tool.name.trim()) + } + } + + const apply = (toolCalls?: any[]) => + toolCalls?.forEach((toolCall) => { + if (typeof toolCall?.name !== 'string') return + const id = toolCall.name + const name = toolNames.get(id) + if (name) Object.assign(toolCall, { id, name }) + }) + + apply(result?.toolCalls) + apply(result?.execution?.output?.toolCalls?.list) + apply(result?.output?.toolCalls?.list) + return result + } + private createResponseMetadata(result: any) { return { tokens: result.tokens || { prompt: 0, completion: 0, total: 0 }, @@ -999,11 +1032,8 @@ export class AgentBlockHandler implements BlockHandler { } private formatToolCall(tc: any) { - const toolName = this.stripCustomToolPrefix(tc.name) - return { ...tc, - name: toolName, startTime: tc.startTime, endTime: tc.endTime, duration: tc.duration, @@ -1011,8 +1041,4 @@ export class AgentBlockHandler implements BlockHandler { result: tc.result || tc.output, } } - - private stripCustomToolPrefix(name: string): string { - return name.startsWith('custom_') ? name.replace('custom_', '') : name - } } diff --git a/apps/tradinggoose/executor/handlers/agent/skill-loader.ts b/apps/tradinggoose/executor/handlers/agent/skill-loader.ts index d8d56f9a2..a1cfdf030 100644 --- a/apps/tradinggoose/executor/handlers/agent/skill-loader.ts +++ b/apps/tradinggoose/executor/handlers/agent/skill-loader.ts @@ -2,6 +2,7 @@ const SKILL_LOADER_MARKER = '__tradinggooseSkillLoader' export const SKILL_LOADER_TOOL_PREFIX = 'tradinggoose_internal_load_skill' export interface SkillMetadata { + id: string name: string description: string } @@ -55,13 +56,13 @@ export function buildSkillsSystemPromptSection( const skillEntries = skills .map( (skillMetadata) => - ` \n ${escapeXml(skillMetadata.description)}\n ` + ` \n ${escapeXml(skillMetadata.description)}\n ` ) .join('\n') return [ '', - `You have access to the following skills. Use the ${skillLoaderToolId} tool to activate a skill when relevant.`, + `You have access to the following skills. Use the ${skillLoaderToolId} tool to activate a skill when relevant. Pass the exact skill_id from the id attribute; skill names are display metadata.`, '', '', skillEntries, @@ -69,24 +70,26 @@ export function buildSkillsSystemPromptSection( ].join('\n') } -export function buildLoadSkillTool(skillLoaderToolId: string, skillNames: string[]) { +export function buildLoadSkillTool(skillLoaderToolId: string, skills: SkillMetadata[]) { + const skillSummaries = skills.map((skill) => `${skill.id} (${skill.name})`).join(', ') + return { id: skillLoaderToolId, name: skillLoaderToolId, - description: `Load a skill to get specialized instructions. Available skills: ${skillNames.join(', ')}`, + description: `Load a skill by exact skill_id to get specialized instructions. Available skill ids: ${skillSummaries}`, params: { [SKILL_LOADER_MARKER]: true, }, parameters: { type: 'object', properties: { - skill_name: { + skill_id: { type: 'string', - enum: skillNames, - description: 'Name of the skill to load', + enum: skills.map((skill) => skill.id), + description: 'Exact skill id from available_skills; do not pass the display name', }, }, - required: ['skill_name'], + required: ['skill_id'], }, } } diff --git a/apps/tradinggoose/executor/handlers/agent/skills-resolver.test.ts b/apps/tradinggoose/executor/handlers/agent/skills-resolver.test.ts new file mode 100644 index 000000000..c0b3f90ea --- /dev/null +++ b/apps/tradinggoose/executor/handlers/agent/skills-resolver.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { resolveSkillMetadata } from './skills-resolver' + +const readSavedEntityFieldsForExecutionMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/yjs/server/bootstrap-review-target', () => ({ + readSavedEntityFieldsForExecution: readSavedEntityFieldsForExecutionMock, +})) + +describe('resolveSkillMetadata', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('skips unavailable selected skills without aborting agent startup', async () => { + readSavedEntityFieldsForExecutionMock.mockImplementation( + async (_entityKind, skillId: string) => { + if (skillId === 'deleted-skill') { + const error = new Error('Saved skill deleted-skill was not found') + Object.assign(error, { status: 404 }) + throw error + } + + return { + name: 'Market Research', + description: 'Research market setup before acting', + } + } + ) + + await expect( + resolveSkillMetadata( + [{ skillId: 'skill-1' }, { skillId: 'deleted-skill' }], + 'workspace-1', + true + ) + ).resolves.toEqual([ + { + id: 'skill-1', + name: 'Market Research', + description: 'Research market setup before acting', + }, + ]) + }) +}) diff --git a/apps/tradinggoose/executor/handlers/agent/skills-resolver.ts b/apps/tradinggoose/executor/handlers/agent/skills-resolver.ts index 2fcce4fca..ff690fb9e 100644 --- a/apps/tradinggoose/executor/handlers/agent/skills-resolver.ts +++ b/apps/tradinggoose/executor/handlers/agent/skills-resolver.ts @@ -1,13 +1,14 @@ import { createLogger } from '@/lib/logs/console/logger' -import { listSkills } from '@/lib/skills/operations' +import { readSavedEntityFieldsForExecution } from '@/lib/yjs/server/bootstrap-review-target' import type { SkillInput } from '@/executor/handlers/agent/types' import type { SkillMetadata } from './skill-loader' -const logger = createLogger('SkillsResolver') +const logger = createLogger('AgentSkillsResolver') export async function resolveSkillMetadata( skillInputs: SkillInput[], - workspaceId: string + workspaceId: string, + isDeployedContext: boolean ): Promise { const skillIds = skillInputs .map((skillInput) => skillInput.skillId) @@ -17,38 +18,39 @@ export async function resolveSkillMetadata( return [] } - try { - const skills = await listSkills({ workspaceId }) - const selectedSkillIds = new Set(skillIds) - return skills - .filter((skill) => selectedSkillIds.has(skill.id)) - .map((skill) => ({ name: skill.name, description: skill.description })) - } catch (error) { - logger.error('Failed to resolve skill metadata', { error, skillIds, workspaceId }) + const results = await Promise.allSettled( + skillIds.map(async (skillId) => { + const fields = await readSavedEntityFieldsForExecution( + 'skill', + skillId, + workspaceId, + isDeployedContext + ) + return { + id: skillId, + name: String(fields.name ?? ''), + description: String(fields.description ?? ''), + } + }) + ) + + return results.flatMap((result, index) => { + if (result.status === 'fulfilled') return [result.value] + logger.warn(`Skipping unavailable agent skill ${skillIds[index]}:`, result.reason) return [] - } + }) } export async function resolveSkillContent( - skillName: string, - workspaceId: string -): Promise { - if (!skillName || !workspaceId) { - return null - } - - try { - const rows = await listSkills({ workspaceId }) - const skill = rows.find((row) => row.name === skillName) - - if (!skill) { - logger.warn('Skill not found', { skillName, workspaceId }) - return null - } - - return skill.content - } catch (error) { - logger.error('Failed to resolve skill content', { error, skillName, workspaceId }) - return null - } + skillId: string, + workspaceId: string, + isDeployedContext: boolean +): Promise { + const fields = await readSavedEntityFieldsForExecution( + 'skill', + skillId, + workspaceId, + isDeployedContext + ) + return String(fields.content ?? '') } diff --git a/apps/tradinggoose/executor/handlers/agent/types.ts b/apps/tradinggoose/executor/handlers/agent/types.ts index 7c1981943..5437ac927 100644 --- a/apps/tradinggoose/executor/handlers/agent/types.ts +++ b/apps/tradinggoose/executor/handlers/agent/types.ts @@ -25,6 +25,7 @@ export interface ToolInput { type?: string schema?: any title?: string + toolId?: string code?: string params?: Record timeout?: number diff --git a/apps/tradinggoose/executor/index.test.ts b/apps/tradinggoose/executor/index.test.ts index c0ee1940c..933ea25f4 100644 --- a/apps/tradinggoose/executor/index.test.ts +++ b/apps/tradinggoose/executor/index.test.ts @@ -141,7 +141,7 @@ describe('Executor', () => { const validateSpy = vi.spyOn(executor as any, 'validateWorkflow') validateSpy.mockClear() - await executor.execute('test-workflow-id') + await executor.execute('test-workflow-id', 'trigger') expect(validateSpy).toHaveBeenCalledTimes(1) }) @@ -283,7 +283,7 @@ describe('Executor', () => { const workflow = createMinimalWorkflow() const executor = createTestExecutor(workflow) - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') expect(result).toHaveProperty('success') expect(result).toHaveProperty('output') @@ -302,7 +302,7 @@ describe('Executor', () => { }, }) - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') expect(result).toHaveProperty('success') expect(result).toHaveProperty('output') @@ -326,7 +326,7 @@ describe('Executor', () => { // Spy on createExecutionContext to verify context extensions are passed const createContextSpy = vi.spyOn(executor as any, 'createExecutionContext') - await executor.execute('test-workflow-id') + await executor.execute('test-workflow-id', 'trigger') expect(createContextSpy).toHaveBeenCalled() const contextArg = createContextSpy.mock.calls[0][2] // third argument is startTime, context is created internally @@ -341,7 +341,7 @@ describe('Executor', () => { const workflow = createWorkflowWithCondition() const executor = createTestExecutor(workflow) - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') // Verify execution completes and returns expected structure if ('success' in result) { @@ -357,7 +357,7 @@ describe('Executor', () => { const workflow = createWorkflowWithLoop() const executor = createTestExecutor(workflow) - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') expect(result).toHaveProperty('success') expect(result).toHaveProperty('output') @@ -555,7 +555,7 @@ describe('Executor', () => { }, }) - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') expect(result).toHaveProperty('success') expect(result).toHaveProperty('output') @@ -573,7 +573,7 @@ describe('Executor', () => { const createContextSpy = vi.spyOn(executor as any, 'createExecutionContext') - await executor.execute('test-workflow-id') + await executor.execute('test-workflow-id', 'trigger') expect(createContextSpy).toHaveBeenCalled() }) @@ -610,7 +610,7 @@ describe('Executor', () => { }, ] - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') expect(result.success).toBe(false) expect(result.error).toContain('Provider stream failed') @@ -913,7 +913,7 @@ describe('Executor', () => { executor.cancel() // Try to execute - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') // Should immediately return cancelled result if ('success' in result) { @@ -931,7 +931,7 @@ describe('Executor', () => { ;(executor as any).isCancelled = true - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') // Should return cancelled result if ('success' in result) { @@ -1019,7 +1019,7 @@ describe('Executor', () => { updateExecutionPaths: vi.fn(), } - const result = await executor.execute('test-workflow') + const result = await executor.execute('test-workflow', 'trigger') // Should succeed with partial results - not throw an error expect(result).toBeDefined() @@ -1143,7 +1143,7 @@ describe('Executor', () => { workflowInput: {}, }) - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') // Verify execution completed (may succeed or fail depending on child workflow availability) expect(result).toBeDefined() @@ -1192,7 +1192,7 @@ describe('Executor', () => { }) // Verify that child executor is created with isChildExecution flag - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') expect(result).toBeDefined() if ('success' in result) { @@ -1274,7 +1274,7 @@ describe('Executor', () => { workflowInput: {}, }) - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') // Verify execution completed (may succeed or fail depending on child workflow availability) expect(result).toBeDefined() @@ -1342,7 +1342,7 @@ describe('Executor', () => { workflowInput: {}, }) - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') // Verify execution completed (may succeed or fail depending on child workflow availability) expect(result).toBeDefined() @@ -1396,7 +1396,7 @@ describe('Executor', () => { workflowInput: {}, }) - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') // Verify that child workflow errors propagate to parent expect(result).toBeDefined() diff --git a/apps/tradinggoose/executor/index.ts b/apps/tradinggoose/executor/index.ts index 3265c396b..2b10dcf92 100644 --- a/apps/tradinggoose/executor/index.ts +++ b/apps/tradinggoose/executor/index.ts @@ -260,10 +260,10 @@ export class Executor { * Executes the workflow and returns the result. * * @param workflowId - Unique identifier for the workflow execution - * @param startBlockId - Optional block ID to start execution from (for webhook or schedule triggers) + * @param triggerBlockId - Trigger block ID to execute from * @returns Execution result containing output, logs, and metadata */ - async execute(workflowId: string, startBlockId?: string): Promise { + async execute(workflowId: string, triggerBlockId: string): Promise { const startTime = new Date() let finalOutput: NormalizedBlockOutput = {} @@ -275,9 +275,9 @@ export class Executor { startTime: startTime.toISOString(), }) - this.validateWorkflow(startBlockId) + this.validateWorkflow(triggerBlockId) - const context = this.createExecutionContext(workflowId, startTime, startBlockId) + const context = this.createExecutionContext(workflowId, startTime, triggerBlockId) try { let hasMoreLayers = true @@ -517,16 +517,15 @@ export class Executor { * Validates that the workflow meets requirements for execution. * Ensures trigger blocks exist along with valid connections and loop configurations. * - * @param startBlockId - Optional specific block to start from + * @param triggerBlockId - Trigger block to execute from * @throws Error if workflow validation fails */ - private validateWorkflow(startBlockId?: string): void { - if (startBlockId) { - const startBlock = this.actualWorkflow.blocks.find((block) => block.id === startBlockId) - if (!startBlock || !startBlock.enabled) { - throw new Error(`Start block ${startBlockId} not found or disabled`) + private validateWorkflow(triggerBlockId?: string): void { + if (triggerBlockId !== undefined) { + const triggerBlock = this.actualWorkflow.blocks.find((block) => block.id === triggerBlockId) + if (!triggerBlock || !triggerBlock.enabled) { + throw new Error(`Trigger block ${triggerBlockId} not found or disabled`) } - return } // Check for any type of trigger block (dedicated triggers or trigger-mode blocks) @@ -584,13 +583,13 @@ export class Executor { * * @param workflowId - Unique identifier for the workflow execution * @param startTime - Execution start time - * @param startBlockId - Optional specific block to start from + * @param triggerBlockId - Trigger block to execute from * @returns Initialized execution context */ private createExecutionContext( workflowId: string, startTime: Date, - startBlockId?: string + triggerBlockId: string ): ExecutionContext { const workspaceId = this.requireExecutionWorkspaceId() const context: ExecutionContext = { @@ -601,6 +600,7 @@ export class Executor { workflowLogId: this.contextExtensions.workflowLogId, submissionSource: this.contextExtensions.submissionSource, triggerType: this.contextExtensions.triggerType, + triggerBlockId: undefined, workflowDepth: this.contextExtensions.workflowDepth ?? 0, isDeployedContext: this.contextExtensions.isDeployedContext || false, blockStates: new Map(), @@ -645,31 +645,11 @@ export class Executor { } } - // Determine which block to initialize as the starting point - let initBlock: SerializedBlock | undefined - if (startBlockId) { - initBlock = this.actualWorkflow.blocks.find((block) => block.id === startBlockId) - } else if (this.isChildExecution) { - const inputTriggerBlocks = this.actualWorkflow.blocks.filter( - (block) => block.metadata?.id === 'input_trigger' - ) - if (inputTriggerBlocks.length === 1) { - initBlock = inputTriggerBlocks[0] - } else if (inputTriggerBlocks.length > 1) { - throw new Error('Child workflow has multiple Input Trigger blocks. Keep only one.') - } - } else { - const triggerBlocks = this.actualWorkflow.blocks.filter((block) => - isSerializedTriggerBlock(block) - ) - if (triggerBlocks.length > 0) { - initBlock = triggerBlocks[0] - } - } - + const initBlock = this.actualWorkflow.blocks.find((block) => block.id === triggerBlockId) if (!initBlock) { - throw new Error('Unable to determine a trigger block to initialize') + throw new Error(`Trigger block ${triggerBlockId} not found or disabled`) } + context.triggerBlockId = initBlock.id // Remove any pre-populated state for the init block so we can inject runtime trigger input. if (context.blockStates.has(initBlock.id)) { diff --git a/apps/tradinggoose/executor/resolver/resolver.test.ts b/apps/tradinggoose/executor/resolver/resolver.test.ts index 5f78ff543..18da3034f 100644 --- a/apps/tradinggoose/executor/resolver/resolver.test.ts +++ b/apps/tradinggoose/executor/resolver/resolver.test.ts @@ -87,6 +87,7 @@ describe('InputResolver', () => { mockContext = { workflowId: 'test-workflow', workflow: sampleWorkflow, + triggerBlockId: 'trigger-block', blockStates: new Map([ [ 'trigger-block', @@ -341,7 +342,7 @@ describe('InputResolver', () => { expect(result.nameRef).toBe('Hello World') // Should resolve using block name }) - it('should handle the special "start" alias for trigger block', () => { + it('should resolve the runtime trigger block through ', () => { const block: SerializedBlock = { id: 'test-block', metadata: { id: 'generic', name: 'Test Block' }, @@ -1338,6 +1339,7 @@ describe('InputResolver', () => { contextWithConnections = { workflowId: 'test-workflow', workspaceId: 'test-workspace-id', + triggerBlockId: 'trigger-1', blockStates: new Map([ ['trigger-1', { output: { input: 'Hello World' }, executed: true, executionTime: 0 }], ['agent-1', { output: { content: 'Agent response' }, executed: true, executionTime: 0 }], @@ -1446,6 +1448,43 @@ describe('InputResolver', () => { expect(result.code).toBe('return "Hello World"') // Should be quoted for function blocks }) + it('resolves start references from the runtime trigger block', () => { + const workflow: SerializedWorkflow = { + ...workflowWithConnections, + blocks: [ + ...workflowWithConnections.blocks, + { + id: 'schedule-trigger', + metadata: { id: 'schedule', name: 'Schedule', category: 'triggers' }, + position: { x: 0, y: 0 }, + config: { tool: 'schedule', params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + }, + ], + } + const resolver = createInputResolver(workflow) + const context = { + ...contextWithConnections, + workflow, + triggerBlockId: 'schedule-trigger', + blockStates: new Map([ + ...contextWithConnections.blockStates, + ['trigger-1', { output: { symbol: 'WRONG' }, executed: true, executionTime: 0 }], + ['schedule-trigger', { output: { symbol: 'AAPL' }, executed: true, executionTime: 0 }], + ]), + } + + const result = resolver.resolveBlockReferences( + 'return ', + context, + workflow.blocks.find((block) => block.id === 'function-1')! + ) + + expect(result).toBe('return AAPL') + }) + it('should format start.input properly for different block types', () => { // Test function block - should quote strings const functionBlock: SerializedBlock = { @@ -2611,7 +2650,7 @@ describe('InputResolver', () => { expect(result.deep4).toBe('12') }) - it.concurrent('should handle start block with 2D array access', () => { + it.concurrent('should handle trigger input with 2D array access', () => { arrayContext.blockStates.set('trigger-block', { output: { input: 'Hello World', @@ -3135,6 +3174,7 @@ describe('InputResolver', () => { workflowId: 'test-parallel-workflow', workspaceId: 'test-workspace-id', workflow: parallelWorkflow, + triggerBlockId: 'start-block', blockStates: new Map([ [ 'function1-block', diff --git a/apps/tradinggoose/executor/resolver/resolver.ts b/apps/tradinggoose/executor/resolver/resolver.ts index 85d0144ab..82a3786f1 100644 --- a/apps/tradinggoose/executor/resolver/resolver.ts +++ b/apps/tradinggoose/executor/resolver/resolver.ts @@ -1,8 +1,7 @@ import { createLogger } from '@/lib/logs/console/logger' import { VariableManager } from '@/lib/variables/variable-manager' -import { evaluateSubBlockConditionValues } from '@/lib/workflows/sub-block-conditions' import { extractReferencePrefixes, SYSTEM_REFERENCE_PREFIXES } from '@/lib/workflows/references' -import { TRIGGER_REFERENCE_ALIAS_MAP } from '@/lib/workflows/triggers' +import { evaluateSubBlockConditionValues } from '@/lib/workflows/sub-block-conditions' import { getBlock } from '@/blocks/index' import type { LoopManager } from '@/executor/loops/loops' import type { ExecutionContext } from '@/executor/types' @@ -40,11 +39,6 @@ export class InputResolver { ]) ) - const startAliasBlock = this.findStartAliasBlock() - if (startAliasBlock) { - this.blockByNormalizedName.set('start', startAliasBlock) - } - // Create efficient loop lookup map this.loopsByBlockId = new Map() for (const [loopId, loop] of Object.entries(workflow.loops || {})) { @@ -62,18 +56,6 @@ export class InputResolver { } } - private findStartAliasBlock(): SerializedBlock | undefined { - const preferredTypes = ['input_trigger', 'api_trigger', 'manual_trigger'] - for (const type of preferredTypes) { - const candidate = this.workflow.blocks.find((block) => block.metadata?.id === type) - if (candidate) { - return candidate - } - } - - return this.workflow.blocks.find((block) => block.metadata?.category === 'triggers') - } - /** * Filters inputs based on sub-block conditions * @param block - Block to filter inputs for @@ -409,149 +391,140 @@ export class InputResolver { // System references (start, loop, parallel, variable) and regular block references are both processed // Accessibility validation happens later in validateBlockReference - // Special case for trigger block references (start, api, chat, manual) + // Special case for the runtime trigger reference. const blockRefLower = blockRef.toLowerCase() - const triggerType = - TRIGGER_REFERENCE_ALIAS_MAP[blockRefLower as keyof typeof TRIGGER_REFERENCE_ALIAS_MAP] - if (triggerType) { - const triggerBlock = this.workflow.blocks.find( - (block) => block.metadata?.id === triggerType - ) - if (triggerBlock) { - const blockState = context.blockStates.get(triggerBlock.id) - if (blockState) { - // For trigger blocks, start directly with the flattened output - // This enables direct access to , , etc - let replacementValue: any = blockState.output - - for (const part of pathParts) { - if (!replacementValue || typeof replacementValue !== 'object') { - logger.warn( - `[resolveBlockReferences] Invalid path "${part}" - replacementValue is not an object:`, - replacementValue + if (blockRefLower === 'start') { + const triggerBlock = context.triggerBlockId + ? this.blockById.get(context.triggerBlockId) + : undefined + if (!triggerBlock) { + throw new Error('Runtime trigger block is not available for reference.') + } + const blockState = context.blockStates.get(triggerBlock.id) + if (blockState) { + // Runtime trigger outputs are exposed through . + let replacementValue: any = blockState.output + + for (const part of pathParts) { + if (!replacementValue || typeof replacementValue !== 'object') { + logger.warn( + `[resolveBlockReferences] Invalid path "${part}" - replacementValue is not an object:`, + replacementValue + ) + throw new Error(`Invalid path "${part}" in "${path}" for trigger block.`) + } + + // Handle array indexing syntax like "files[0]" or "items[1]" + const arrayMatch = part.match(/^([^[]+)\[(\d+)\]$/) + if (arrayMatch) { + const [, arrayName, indexStr] = arrayMatch + const index = Number.parseInt(indexStr, 10) + + // First access the array property + const arrayValue = replacementValue[arrayName] + if (!Array.isArray(arrayValue)) { + throw new Error( + `Property "${arrayName}" is not an array in path "${path}" for trigger block.` ) - throw new Error(`Invalid path "${part}" in "${path}" for trigger block.`) } - // Handle array indexing syntax like "files[0]" or "items[1]" - const arrayMatch = part.match(/^([^[]+)\[(\d+)\]$/) - if (arrayMatch) { - const [, arrayName, indexStr] = arrayMatch - const index = Number.parseInt(indexStr, 10) - - // First access the array property - const arrayValue = replacementValue[arrayName] - if (!Array.isArray(arrayValue)) { - throw new Error( - `Property "${arrayName}" is not an array in path "${path}" for trigger block.` - ) - } - - // Then access the array element - if (index < 0 || index >= arrayValue.length) { - throw new Error( - `Array index ${index} is out of bounds for "${arrayName}" (length: ${arrayValue.length}) in path "${path}" for trigger block.` - ) - } - - replacementValue = arrayValue[index] - } else if (/^(?:[^[]+(?:\[\d+\])+|(?:\[\d+\])+)$/.test(part)) { - // Enhanced: support multiple indices like "values[0][0]" - replacementValue = this.resolvePartWithIndices( - replacementValue, - part, - path, - 'trigger block' + // Then access the array element + if (index < 0 || index >= arrayValue.length) { + throw new Error( + `Array index ${index} is out of bounds for "${arrayName}" (length: ${arrayValue.length}) in path "${path}" for trigger block.` ) - } else { - if (Array.isArray(replacementValue)) { - throw new Error( - `Array path "${path}" in trigger block must use an explicit index.` - ) - } - replacementValue = resolvePropertyAccess(replacementValue, part) } - if (replacementValue === undefined) { - logger.warn( - `[resolveBlockReferences] No value found at path "${part}" in trigger block.` - ) - throw new Error(`No value found at path "${path}" in trigger block.`) + replacementValue = arrayValue[index] + } else if (/^(?:[^[]+(?:\[\d+\])+|(?:\[\d+\])+)$/.test(part)) { + // Enhanced: support multiple indices like "values[0][0]" + replacementValue = this.resolvePartWithIndices( + replacementValue, + part, + path, + 'trigger block' + ) + } else { + if (Array.isArray(replacementValue)) { + throw new Error(`Array path "${path}" in trigger block must use an explicit index.`) } + replacementValue = resolvePropertyAccess(replacementValue, part) } - // Format the value based on block type and path - let formattedValue: string - - // Special handling for all blocks referencing trigger input - // For start and chat triggers, check for 'input' field. For API trigger, any field access counts - const isTriggerInputRef = - (blockRefLower === 'start' && pathParts.join('.').includes('input')) || - (blockRefLower === 'chat' && pathParts.join('.').includes('input')) || - (blockRefLower === 'api' && pathParts.length > 0) - if (isTriggerInputRef) { - const blockType = currentBlock.metadata?.id - - // Format based on which block is consuming this value - if (typeof replacementValue === 'object' && replacementValue !== null) { - // For function blocks, preserve the object structure for code usage - if (blockType === 'function') { - formattedValue = JSON.stringify(replacementValue) - } - // For API blocks, handle body special case - else if (blockType === 'api') { - formattedValue = JSON.stringify(replacementValue) - } - // For condition blocks, ensure proper formatting - else if (blockType === 'condition') { - formattedValue = this.stringifyForCondition(replacementValue) - } - // For response blocks, preserve object structure as-is for proper JSON response - else if (blockType === 'response') { - formattedValue = replacementValue - } - // For all other blocks, stringify objects - else { - // Preserve full JSON structure for objects - formattedValue = JSON.stringify(replacementValue) - } - } else { - // For primitive values, format based on target block type - if (blockType === 'function') { - formattedValue = this.formatValueForCodeContext( - replacementValue, - currentBlock, - isInTemplateLiteral - ) - } else if (blockType === 'condition') { - formattedValue = this.stringifyForCondition(replacementValue) - } else { - formattedValue = String(replacementValue) - } + if (replacementValue === undefined) { + logger.warn( + `[resolveBlockReferences] No value found at path "${part}" in trigger block.` + ) + throw new Error(`No value found at path "${path}" in trigger block.`) + } + } + + // Format the value based on block type and path + let formattedValue: string + + const isTriggerInputRef = pathParts.join('.').includes('input') + if (isTriggerInputRef) { + const blockType = currentBlock.metadata?.id + + // Format based on which block is consuming this value + if (typeof replacementValue === 'object' && replacementValue !== null) { + // For function blocks, preserve the object structure for code usage + if (blockType === 'function') { + formattedValue = JSON.stringify(replacementValue) + } + // For API blocks, handle body special case + else if (blockType === 'api') { + formattedValue = JSON.stringify(replacementValue) + } + // For condition blocks, ensure proper formatting + else if (blockType === 'condition') { + formattedValue = this.stringifyForCondition(replacementValue) + } + // For response blocks, preserve object structure as-is for proper JSON response + else if (blockType === 'response') { + formattedValue = replacementValue + } + // For all other blocks, stringify objects + else { + // Preserve full JSON structure for objects + formattedValue = JSON.stringify(replacementValue) } } else { - // Standard handling for non-input references - const blockType = currentBlock.metadata?.id - - if (blockType === 'response') { - // For response blocks, properly quote string values for JSON context - if (typeof replacementValue === 'string') { - // Properly escape and quote the string for JSON - formattedValue = JSON.stringify(replacementValue) - } else { - formattedValue = replacementValue - } + // For primitive values, format based on target block type + if (blockType === 'function') { + formattedValue = this.formatValueForCodeContext( + replacementValue, + currentBlock, + isInTemplateLiteral + ) + } else if (blockType === 'condition') { + formattedValue = this.stringifyForCondition(replacementValue) } else { - formattedValue = - typeof replacementValue === 'object' - ? JSON.stringify(replacementValue) - : String(replacementValue) + formattedValue = String(replacementValue) } } - - resolvedValue = resolvedValue.replace(raw, formattedValue) - continue + } else { + // Standard handling for non-input references + const blockType = currentBlock.metadata?.id + + if (blockType === 'response') { + // For response blocks, properly quote string values for JSON context + if (typeof replacementValue === 'string') { + // Properly escape and quote the string for JSON + formattedValue = JSON.stringify(replacementValue) + } else { + formattedValue = replacementValue + } + } else { + formattedValue = + typeof replacementValue === 'object' + ? JSON.stringify(replacementValue) + : String(replacementValue) + } } + + resolvedValue = resolvedValue.replace(raw, formattedValue) + continue } } @@ -1013,10 +986,7 @@ export class InputResolver { const accessibleBlocks = this.getAccessibleBlocks(currentBlockId) const isAlwaysAccessibleTrigger = sourceBlock.metadata?.category === 'triggers' || - sourceBlock.metadata?.id === 'input_trigger' || - sourceBlock.metadata?.id === 'api_trigger' || - sourceBlock.metadata?.id === 'manual_trigger' || - sourceBlock.metadata?.id === 'chat_trigger' + sourceBlock.config.params.triggerMode === true if ( sourceBlock.id !== currentBlockId && diff --git a/apps/tradinggoose/executor/tests/executor-layer-validation.test.ts b/apps/tradinggoose/executor/tests/executor-layer-validation.test.ts index 839fda7e0..e4af0f305 100644 --- a/apps/tradinggoose/executor/tests/executor-layer-validation.test.ts +++ b/apps/tradinggoose/executor/tests/executor-layer-validation.test.ts @@ -167,7 +167,7 @@ describe('Full Executor Test', () => { try { // Execute the workflow - const result = await executor.execute('test-workflow-id') + const result = await executor.execute('test-workflow-id', 'trigger') // Check if it's an ExecutionResult (not StreamingExecution) if ('success' in result) { @@ -186,7 +186,11 @@ describe('Full Executor Test', () => { it('should test the executor getNextExecutionLayer method directly', async () => { // Create a mock context in the exact state after the condition executes - const context = (executor as any).createExecutionContext('test-workflow', new Date()) + const context = (executor as any).createExecutionContext( + 'test-workflow', + new Date(), + 'bd9f4f7d-8aed-4860-a3be-8bebd1931b19' + ) // Set up the state as it would be after the condition executes context.executedBlocks.add('bd9f4f7d-8aed-4860-a3be-8bebd1931b19') // Start diff --git a/apps/tradinggoose/executor/tests/multi-input-routing.test.ts b/apps/tradinggoose/executor/tests/multi-input-routing.test.ts index 8d3c7663b..818a72789 100644 --- a/apps/tradinggoose/executor/tests/multi-input-routing.test.ts +++ b/apps/tradinggoose/executor/tests/multi-input-routing.test.ts @@ -101,9 +101,9 @@ describe('Multi-Input Routing Scenarios', () => { it('should handle multi-input target when router selects function-1', async () => { // Test scenario: Router selects function-1, agent should still execute with function-1's output - const context = (executor as any).createExecutionContext('test-workflow', new Date()) + const context = (executor as any).createExecutionContext('test-workflow', new Date(), 'start') - // Step 1: Execute start block + // Step 1: Execute trigger block context.executedBlocks.add('start') context.activeExecutionPath.add('start') context.activeExecutionPath.add('router-1') @@ -166,7 +166,7 @@ describe('Multi-Input Routing Scenarios', () => { it('should handle multi-input target when router selects function-2', async () => { // Test scenario: Router selects function-2, agent should still execute with function-2's output - const context = (executor as any).createExecutionContext('test-workflow', new Date()) + const context = (executor as any).createExecutionContext('test-workflow', new Date(), 'start') // Step 1: Execute start and router-1 selecting function-2 context.executedBlocks.add('start') @@ -223,7 +223,7 @@ describe('Multi-Input Routing Scenarios', () => { it('should verify the dependency logic for inactive sources', async () => { // This test specifically validates the multi-input dependency logic - const context = (executor as any).createExecutionContext('test-workflow', new Date()) + const context = (executor as any).createExecutionContext('test-workflow', new Date(), 'start') // Setup: Router executed and selected function-1, function-1 executed context.executedBlocks.add('start') diff --git a/apps/tradinggoose/executor/types.ts b/apps/tradinggoose/executor/types.ts index 4ce4e86b7..eab9cd07f 100644 --- a/apps/tradinggoose/executor/types.ts +++ b/apps/tradinggoose/executor/types.ts @@ -115,6 +115,7 @@ export interface ExecutionContext { workflowLogId?: string submissionSource?: ExecutionSubmissionSource triggerType?: TriggerType + triggerBlockId?: string workflowDepth?: number // Whether this execution is running against deployed state (API/webhook/schedule/chat) // Manual executions in the builder should leave this undefined/false diff --git a/apps/tradinggoose/global-navbar/components/user-menu.test.tsx b/apps/tradinggoose/global-navbar/components/user-menu.test.tsx index 9922f4d26..28fbf3c37 100644 --- a/apps/tradinggoose/global-navbar/components/user-menu.test.tsx +++ b/apps/tradinggoose/global-navbar/components/user-menu.test.tsx @@ -15,6 +15,7 @@ const mockRefresh = vi.fn() const mockReplaceLocaleDocument = vi.fn() const mockSetTheme = vi.fn() const mockUpdateSetting = vi.fn() +const mockOpenSettings = vi.fn() let mockPathname = '/workspace/ws-1/dashboard' let mockSearchParams = '' @@ -91,12 +92,25 @@ vi.mock('@/global-navbar/settings-modal/components/help/help-modal', () => ({ HelpModal: () => null, })) -function renderUserMenu(root: Root, locale: LocaleCode) { +function renderUserMenu( + root: Root, + locale: LocaleCode, + options: { canAccessSystemAdmin?: boolean; sidebarTrigger?: boolean } = {} +) { + const userMenu = ( + + ) + root.render( - - - + {options.sidebarTrigger ? {userMenu} : userMenu} ) } @@ -109,7 +123,9 @@ async function openMenu(button: HTMLButtonElement) { } function getUserMenuButton(container: HTMLElement) { - const button = container.querySelector('button[data-sidebar="menu-button"]') + const button = Array.from(container.querySelectorAll('button')).find((candidate) => + candidate.getAttribute('aria-label')?.startsWith('Ada Lovelace ') + ) if (!(button instanceof HTMLButtonElement)) { throw new Error('Expected user menu trigger to render') } @@ -179,6 +195,7 @@ describe('UserMenu language selector', () => { mockReplaceLocaleDocument.mockReset() mockSetTheme.mockReset() mockUpdateSetting.mockReset() + mockOpenSettings.mockReset() mockUpdateSetting.mockResolvedValue(undefined) mockPathname = '/workspace/ws-1/dashboard' mockSearchParams = '' @@ -212,6 +229,62 @@ describe('UserMenu language selector', () => { expect(getThemeButton('主题:系统')).toBeInTheDocument() }) + it('renders the compact avatar trigger outside a sidebar context', async () => { + await act(async () => { + renderUserMenu(root, 'en') + await flush() + }) + + const button = getUserMenuButton(container) + expect(button.textContent).toBe('AL') + expect(container.querySelector('[data-sidebar="menu"]')).toBeNull() + expect(container.querySelector('button[data-sidebar="menu-button"]')).toBeNull() + }) + + it('renders the sidebar trigger with user details inside the global navbar sidebar', async () => { + await act(async () => { + renderUserMenu(root, 'en', { sidebarTrigger: true }) + await flush() + }) + + const button = getUserMenuButton(container) + expect(button.getAttribute('data-sidebar')).toBe('menu-button') + expect(button.textContent).toContain('Ada Lovelace') + expect(button.textContent).toContain('ada@example.com') + + await act(async () => { + await openMenu(button) + }) + + const menu = document.body.querySelector('[role="menu"]') + expect(menu?.className).toContain('w-[var(--radix-dropdown-menu-trigger-width)]') + }) + + it('owns the system admin menu item for authorized users', async () => { + await act(async () => { + renderUserMenu(root, 'en', { canAccessSystemAdmin: true }) + await flush() + }) + + await act(async () => { + await openMenu(getUserMenuButton(container)) + }) + + const systemAdminItem = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find( + (item) => item.textContent?.includes(getPublicCopy('en').workspace.nav.systemAdmin) + ) + if (!(systemAdminItem instanceof HTMLElement)) { + throw new Error('Expected system admin menu item to render') + } + + await act(async () => { + systemAdminItem.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await flush() + }) + + expect(mockPush).toHaveBeenCalledWith('/admin') + }) + it('switches to zh without dropping the workspace path or query string', async () => { mockSearchParams = 'layout=main' diff --git a/apps/tradinggoose/global-navbar/components/user-menu.tsx b/apps/tradinggoose/global-navbar/components/user-menu.tsx index 245dba5f2..a6b3f8f73 100644 --- a/apps/tradinggoose/global-navbar/components/user-menu.tsx +++ b/apps/tradinggoose/global-navbar/components/user-menu.tsx @@ -20,8 +20,16 @@ import { Users, } from 'lucide-react' import { useSearchParams } from 'next/navigation' -import { useLocale, useMessages, useTranslations } from 'next-intl' +import { useLocale, useTranslations } from 'next-intl' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar' import { widgetHeaderControlClassName, @@ -40,20 +48,11 @@ import { HelpModal } from '@/global-navbar/settings-modal/components/help/help-m import type { SettingsSection } from '@/global-navbar/settings-modal/types' import { useOrganizationBilling, useOrganizations } from '@/hooks/queries/organization' import { useSubscriptionData } from '@/hooks/queries/subscription' -import { formatTemplate } from '@/i18n/utils' import { replaceLocaleDocument, usePathname, useRouter } from '@/i18n/navigation' import { getLocaleDisplayName, isLocaleCode, type LocaleCode, locales } from '@/i18n/utils' import { clearUserData } from '@/stores' import { useGeneralStore } from '@/stores/settings/general/store' import { getInitials } from '../utils' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from './resizable-dropdown' type ThemeOption = { value: 'light' | 'system' | 'dark' @@ -74,19 +73,9 @@ interface UserMenuProps { userAvatar?: string | null userAvatarVersion?: number | string | null userId?: string | null - onOpenSettings?: (section: SettingsSection) => void - systemNavigation?: { - href: string - label: string - } | null -} - -type UserMenuMessages = { - workspace?: { - userMenu?: { - themeLabel?: string - } - } + onOpenSettings: (section: SettingsSection) => void + canAccessSystemAdmin?: boolean + sidebarTrigger?: boolean } export function UserMenu({ @@ -96,7 +85,8 @@ export function UserMenu({ userAvatarVersion, userId, onOpenSettings, - systemNavigation, + canAccessSystemAdmin = false, + sidebarTrigger = false, }: UserMenuProps) { const router = useRouter() const locale = useLocale() as LocaleCode @@ -104,9 +94,10 @@ export function UserMenu({ const searchParams = useSearchParams() const search = searchParams.toString() const tUserMenu = useTranslations('workspace.userMenu') - const messages = useMessages() as UserMenuMessages + const tWorkspaceNav = useTranslations('workspace.nav') const [isSigningOut, setIsSigningOut] = useState(false) const [isOpeningBillingPortal, setIsOpeningBillingPortal] = useState(false) + const [nameOverride, setNameOverride] = useState(null) const [avatarOverride, setAvatarOverride] = useState<{ url: string | null version: number | string | null @@ -146,8 +137,7 @@ export function UserMenu({ const currentThemeOption = THEME_OPTIONS.find((option) => option.value === theme) ?? THEME_OPTIONS[0] const currentThemeLabel = themeOptionLabels[currentThemeOption.value] - const themeLabelTemplate = messages.workspace?.userMenu?.themeLabel ?? 'Theme: {theme}' - const currentThemeAriaLabel = formatTemplate(themeLabelTemplate, { theme: currentThemeLabel }) + const currentThemeAriaLabel = tUserMenu('themeLabel', { theme: currentThemeLabel }) const [isHelpModalOpen, setIsHelpModalOpen] = useState(false) const activeOrganization = organizationsData?.activeOrganization const activeOrganizationId = activeOrganization?.id @@ -179,6 +169,39 @@ export function UserMenu({ const canOpenTeamSettings = organizationAccess.canOpenTeamSettings const canManageSSOSettings = organizationAccess.canConfigureSso + useEffect(() => { + if (!userId || typeof window === 'undefined') { + setNameOverride(null) + return + } + + const key = `user-name-${userId}` + + const readStoredName = () => { + const storedName = window.localStorage.getItem(key) + setNameOverride(storedName !== null ? storedName || null : null) + } + + const handleStorage = (event: StorageEvent) => { + if (event.key === key) { + readStoredName() + } + } + + const handleNameEvent = (event: Event) => { + const detail = (event as CustomEvent<{ name?: string | null }>).detail + setNameOverride(detail && 'name' in detail ? (detail.name ?? null) : null) + } + + readStoredName() + window.addEventListener('storage', handleStorage) + window.addEventListener('user-name-updated', handleNameEvent) + return () => { + window.removeEventListener('storage', handleStorage) + window.removeEventListener('user-name-updated', handleNameEvent) + } + }, [userId]) + useEffect(() => { if (!userId || typeof window === 'undefined') return @@ -228,6 +251,7 @@ export function UserMenu({ return () => window.removeEventListener('user-avatar-updated', handler) }, []) + const displayUserName = nameOverride ?? userName const effectiveAvatar = avatarOverride.url ?? userAvatar const effectiveVersion = avatarOverride.version ?? userAvatarVersion @@ -308,305 +332,290 @@ export function UserMenu({ } } - return ( - <> - - - + const avatar = ( + + {avatarSrc ? ( + + ) : ( + + )} + {getInitials(displayUserName)} + + ) + const triggerLabel = `${displayUserName} ${userMenuCopy.accountDetail}` + + const menuContent = ( + + +
+ - - - {avatarSrc ? ( - - ) : ( - - )} - {getInitials(userName)} - -
- {userName} - {userEmail} -
- -
+ + +
- -
- - - - - - {THEME_OPTIONS.map(({ value, Icon }) => { - const label = themeOptionLabels[value] - const isActive = theme === value - - return ( - { - if (isActive) { - event.preventDefault() - return - } - void handleThemeChange(value) - }} - > - - ) - })} - - - - - - - - {locales.map((code) => { - const isActive = code === locale - - return ( - { - if (isActive) { - event.preventDefault() - return - } - handleLocaleChange(code) - }} - > - {getLocaleDisplayName(code)} - {isActive ? ( - - ) : null} - - ) - })} - - -
-
- - - { - event.preventDefault() - if (onOpenSettings) { - onOpenSettings('account') - } else if (typeof window !== 'undefined') { - window.dispatchEvent( - new CustomEvent('open-settings', { detail: { tab: 'account' } }) - ) - } - }} - > - - {userMenuCopy.accountDetail} - - {isHosted ? ( + {THEME_OPTIONS.map(({ value, Icon }) => { + const label = themeOptionLabels[value] + const isActive = theme === value + + return ( { - event.preventDefault() - if (onOpenSettings) { - onOpenSettings('service') - } else if (typeof window !== 'undefined') { - window.dispatchEvent( - new CustomEvent('open-settings', { detail: { tab: 'service' } }) - ) + if (isActive) { + event.preventDefault() + return } + void handleThemeChange(value) }} > - - {userMenuCopy.serviceApiKeys} + - ) : null} - - {billingEnabled ? ( - <> - - - { - event.preventDefault() - if (onOpenSettings) { - onOpenSettings('subscription') - } else if (typeof window !== 'undefined') { - window.dispatchEvent( - new CustomEvent('open-settings', { detail: { tab: 'subscription' } }) - ) - } - }} - > - - {userMenuCopy.subscription} - - { - event.preventDefault() - void handleOpenBillingPortal() - }} - > - - {isOpeningBillingPortal - ? userMenuCopy.openingBilling - : userMenuCopy.manageBilling} - - - - ) : null} - {canOpenTeamSettings || canManageSSOSettings ? ( - <> - - - {canOpenTeamSettings ? ( - { - event.preventDefault() - if (onOpenSettings) { - onOpenSettings('team') - } else if (typeof window !== 'undefined') { - window.dispatchEvent( - new CustomEvent('open-settings', { detail: { tab: 'team' } }) - ) - } - }} - > - - {userMenuCopy.teamManagement} - - ) : null} - {canManageSSOSettings ? ( - { - event.preventDefault() - if (onOpenSettings) { - onOpenSettings('sso') - } else if (typeof window !== 'undefined') { - window.dispatchEvent( - new CustomEvent('open-settings', { detail: { tab: 'sso' } }) - ) - } - }} - > - - {userMenuCopy.singleSignOn} - - ) : null} - - - ) : null} - {systemNavigation ? ( - <> - - - { + ) + })} +
+
+ + + + + + {locales.map((code) => { + const isActive = code === locale + + return ( + { + if (isActive) { event.preventDefault() - router.push(systemNavigation.href) - }} - > - - {systemNavigation.label} - - - - ) : null} - - - { - event.preventDefault() - setIsHelpModalOpen(true) - }} - > - - {userMenuCopy.helpSupport} - - - + return + } + handleLocaleChange(code) + }} + > + {getLocaleDisplayName(code)} + {isActive ? : null} + + ) + })} + + +
+
+ + + { + event.preventDefault() + onOpenSettings('account') + }} + > + + {userMenuCopy.accountDetail} + + {isHosted ? ( + { + event.preventDefault() + onOpenSettings('service') + }} + > + + {userMenuCopy.serviceApiKeys} + + ) : null} + + {billingEnabled ? ( + <> + + + { + event.preventDefault() + onOpenSettings('subscription') + }} + > + + {userMenuCopy.subscription} + + { + event.preventDefault() + void handleOpenBillingPortal() + }} + > + + {isOpeningBillingPortal ? userMenuCopy.openingBilling : userMenuCopy.manageBilling} + + + + ) : null} + {canOpenTeamSettings || canManageSSOSettings ? ( + <> + + + {canOpenTeamSettings ? ( { event.preventDefault() - void handleSignOut() + onOpenSettings('team') }} - className='text-destructive focus:text-destructive' > - - {isSigningOut ? userMenuCopy.loggingOut : userMenuCopy.logOut} + + {userMenuCopy.teamManagement} -
-
-
-
+ ) : null} + {canManageSSOSettings ? ( + { + event.preventDefault() + onOpenSettings('sso') + }} + > + + {userMenuCopy.singleSignOn} + + ) : null} + + + ) : null} + {canAccessSystemAdmin ? ( + <> + + + { + event.preventDefault() + router.push('/admin') + }} + > + + {tWorkspaceNav('systemAdmin')} + + + + ) : null} + + + { + event.preventDefault() + setIsHelpModalOpen(true) + }} + > + + {userMenuCopy.helpSupport} + + + + { + event.preventDefault() + void handleSignOut() + }} + className='text-destructive focus:text-destructive' + > + + {isSigningOut ? userMenuCopy.loggingOut : userMenuCopy.logOut} + + + ) + + return ( + <> + + {sidebarTrigger ? ( + + + + + {avatar} +
+ {displayUserName} + {userEmail} +
+ +
+
+
+
+ ) : ( + + + + )} + {menuContent} +
) diff --git a/apps/tradinggoose/global-navbar/components/workspace-dialogs.tsx b/apps/tradinggoose/global-navbar/components/workspace-dialogs.tsx index 4065fd783..df543a542 100644 --- a/apps/tradinggoose/global-navbar/components/workspace-dialogs.tsx +++ b/apps/tradinggoose/global-navbar/components/workspace-dialogs.tsx @@ -2,8 +2,8 @@ import React, { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Loader2, RotateCw, X } from 'lucide-react' -import { useLocale } from 'next-intl' import { useParams } from 'next/navigation' +import { useLocale } from 'next-intl' import { AlertDialog, AlertDialogAction, @@ -18,20 +18,19 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Skeleton } from '@/components/ui/skeleton' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip' -import { useSession } from '@/lib/auth-client' import { quickValidateEmail } from '@/lib/email/validation' import { createLogger } from '@/lib/logs/console/logger' import type { PermissionType } from '@/lib/permissions/utils' import { cn } from '@/lib/utils' import { - WorkspacePermissionsProvider, useUserPermissionsContext, useWorkspacePermissionsContext, + WorkspacePermissionsProvider, } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { useOptionalWorkflowRoute } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' import type { WorkspacePermissions } from '@/hooks/use-workspace-permissions' +import type { LocaleCode } from '@/i18n/utils' import { API_ENDPOINTS } from '@/stores/constants' -import { type LocaleCode } from '@/i18n/utils' +import { useOptionalWorkflowRoute } from '@/widgets/widgets/editor_workflow/context/workflow-route-context' import type { Workspace } from '../types' const logger = createLogger('WorkspaceInviteModal') @@ -39,8 +38,11 @@ const logger = createLogger('WorkspaceInviteModal') interface WorkspaceInviteModalProps { open: boolean onOpenChange: (open: boolean) => void + currentUserId: string + currentUserEmail: string | null workspaceName?: string workspaceId?: string + workspaceOwnerId?: string } interface EmailTagProps { @@ -61,6 +63,8 @@ interface UserPermissions { } interface PermissionsTableProps { + currentUserId: string + currentUserEmail: string | null userPermissions: UserPermissions[] onPermissionChange: (userId: string, permissionType: PermissionType) => void onRemoveMember?: (userId: string, email: string) => void @@ -73,6 +77,7 @@ interface PermissionsTableProps { permissionsLoading: boolean pendingInvitations: UserPermissions[] isPendingInvitationsLoading: boolean + workspaceOwnerId?: string resendingInvitationIds?: Record resentInvitationIds?: Record resendCooldowns?: Record @@ -134,9 +139,7 @@ const PermissionSelector = React.memo<{ ) return ( -
+
{permissionOptions.map((option, index) => ( - - -

{isPendingInvitation ? 'Revoke invite' : 'Remove member'}

-
- - )} + + + + + +

{isPendingInvitation ? 'Revoke invite' : 'Remove member'}

+
+
+ )}
@@ -443,8 +456,11 @@ const PermissionsTable = ({ export function WorkspaceInviteModal({ open, onOpenChange, + currentUserId, + currentUserEmail, workspaceName, workspaceId, + workspaceOwnerId, }: WorkspaceInviteModalProps) { const locale = useLocale() as LocaleCode const formRef = useRef(null) @@ -478,7 +494,6 @@ export function WorkspaceInviteModal({ const resolvedWorkspaceId = workspaceId ?? optionalRoute?.workspaceId ?? (params?.workspaceId as string | undefined) ?? null - const { data: session } = useSession() const { workspacePermissions, permissionsLoading, @@ -486,6 +501,13 @@ export function WorkspaceInviteModal({ refetchPermissions, userPermissions: userPerms, } = useWorkspacePermissionsContext() + const currentUserEmailFromPermissions = workspacePermissions?.users?.find( + (user) => user.userId === currentUserId + )?.email + const normalizedCurrentUserEmail = + currentUserEmailFromPermissions?.trim().toLowerCase() ?? + currentUserEmail?.trim().toLowerCase() ?? + null const hasPendingChanges = Object.keys(existingUserPermissionChanges).length > 0 const hasNewInvites = emails.length > 0 || inputValue.trim() @@ -562,7 +584,7 @@ export function WorkspaceInviteModal({ return false } - if (session?.user?.email && session.user.email.toLowerCase() === normalized) { + if (normalizedCurrentUserEmail === normalized) { setErrorMessage('You cannot invite yourself') setInputValue('') return false @@ -588,7 +610,13 @@ export function WorkspaceInviteModal({ setInputValue('') return true }, - [emails, invalidEmails, pendingInvitations, workspacePermissions?.users, session?.user?.email] + [ + emails, + invalidEmails, + pendingInvitations, + workspacePermissions?.users, + normalizedCurrentUserEmail, + ] ) const removeEmail = useCallback( @@ -655,8 +683,12 @@ export function WorkspaceInviteModal({ throw new Error(data.error || 'Failed to update permissions') } - if (data.users && data.total !== undefined) { - updatePermissions({ users: data.users, total: data.total }) + if (data.users && data.total !== undefined && data.currentUserPermission) { + updatePermissions({ + users: data.users, + total: data.total, + currentUserPermission: data.currentUserPermission, + }) } setExistingUserPermissionChanges({}) @@ -732,6 +764,7 @@ export function WorkspaceInviteModal({ (user) => user.userId !== memberToRemove.userId ) updatePermissions({ + ...workspacePermissions, users: updatedUsers, total: workspacePermissions.total - 1, }) @@ -1047,9 +1080,7 @@ export function WorkspaceInviteModal({ - - Invite members to {workspaceName || 'Workspace'} - + Invite members to {workspaceName || 'Workspace'}
@@ -1104,6 +1135,8 @@ export function WorkspaceInviteModal({
formRef.current?.requestSubmit()} disabled={ - !userPerms.canAdmin || isSubmitting || isSaving || !resolvedWorkspaceId || !hasNewInvites + !userPerms.canAdmin || + isSubmitting || + isSaving || + !resolvedWorkspaceId || + !hasNewInvites } className={cn( 'ml-auto flex h-9 items-center justify-center gap-2 rounded-sm px-4 py-2 font-medium transition-all duration-200', @@ -1249,6 +1287,7 @@ export function WorkspaceInviteModal({ } interface WorkspaceDialogsProps { + workspaceUser: { id: string; email: string | null } | null inviteDialogOpen: boolean onInviteDialogChange: (open: boolean) => void inviteWorkspace: Workspace | null @@ -1261,6 +1300,7 @@ interface WorkspaceDialogsProps { } export function WorkspaceDialogs({ + workspaceUser, inviteDialogOpen, onInviteDialogChange, inviteWorkspace, @@ -1271,19 +1311,42 @@ export function WorkspaceDialogs({ isDeletingWorkspace, onConfirmDelete, }: WorkspaceDialogsProps) { + const inviteIdentityMissing = Boolean(inviteWorkspace && !workspaceUser) + return ( <> - {inviteWorkspace ? ( - + {inviteWorkspace && workspaceUser ? ( + ) : null} + {inviteIdentityMissing ? ( + + + + Workspace session unavailable + + Workspace management requires an authenticated workspace session. + + + + onInviteDialogChange(false)}> + Close + + + + + ) : null} + diff --git a/apps/tradinggoose/global-navbar/global-navbar.tsx b/apps/tradinggoose/global-navbar/global-navbar.tsx index 2b69509d3..ed0d3103f 100644 --- a/apps/tradinggoose/global-navbar/global-navbar.tsx +++ b/apps/tradinggoose/global-navbar/global-navbar.tsx @@ -25,7 +25,6 @@ import { UserMenu } from './components/user-menu' import { WorkspaceDialogs } from './components/workspace-dialogs' import { WorkspaceSwitcher } from './components/workspace-switcher' import { GlobalNavbarHeaderProvider } from './header-context' -import { SettingsLoader } from './settings-loader' import { SettingsDialog } from './settings-modal/settings-dialog' import type { SettingsSection } from './settings-modal/types' import type { NavSection } from './types' @@ -41,10 +40,12 @@ import { export function GlobalNavbar({ children, isSystemAdmin = false, + workspaceUser = null, navigationMode = 'workspace', }: { children: React.ReactNode isSystemAdmin?: boolean + workspaceUser?: { id: string; email: string | null } | null navigationMode?: 'workspace' | 'admin' }) { const selectedSegments = useSelectedLayoutSegments() @@ -118,35 +119,20 @@ export function GlobalNavbar({ const [activeSettingsSection, setActiveSettingsSection] = React.useState('account') const [isSettingsModalOpen, setIsSettingsModalOpen] = React.useState(false) - const [userNameOverride, setUserNameOverride] = React.useState(null) - const [userAvatarOverride, setUserAvatarOverride] = React.useState<{ - url: string | null - version: number | string | null - }>({ url: null, version: null }) const userId = sessionData?.user?.id ?? null - const userName = userNameOverride ?? sessionData?.user?.name ?? brand.name + const userName = sessionData?.user?.name ?? brand.name const userEmail = sessionData?.user?.email ?? brand.supportEmail ?? 'support@tradinggoose.ai' - const userAvatar = userAvatarOverride.url ?? sessionData?.user?.image - const userAvatarVersion = - userAvatarOverride.version ?? - (sessionData?.user?.updatedAt ? new Date(sessionData.user.updatedAt).getTime() : null) + const userAvatar = sessionData?.user?.image + const userAvatarVersion = sessionData?.user?.updatedAt + ? new Date(sessionData.user.updatedAt).getTime() + : null const workspaceSwitcher = useWorkspaceSwitcher({ enabled: isAuthenticated && !isSessionLoading, workspaceId, section: workspaceSection, }) const canManageWorkspaces = workspaceSwitcher.canManageWorkspaces - const systemNavigation = React.useMemo(() => { - if (!isSystemAdmin || navigationMode === 'admin') { - return null - } - - return { - href: '/admin', - label: tWorkspaceNav('systemAdmin'), - } - }, [isSystemAdmin, navigationMode, tWorkspaceNav]) const resolveSettingsSection = React.useCallback( (section: SettingsSection): SettingsSection => { @@ -205,84 +191,6 @@ export function GlobalNavbar({ } }, [openSettings]) - React.useEffect(() => { - if (!userId || typeof window === 'undefined') { - setUserNameOverride(null) - return - } - - const key = `user-name-${userId}` - - const readStoredName = () => { - const storedName = window.localStorage.getItem(key) - setUserNameOverride(storedName !== null ? storedName || null : null) - } - - const handleStorage = (event: StorageEvent) => { - if (!event.key || event.key !== key) return - readStoredName() - } - - const handleNameEvent = (event: Event) => { - const customEvent = event as CustomEvent<{ name?: string | null }> - const detail = customEvent.detail - setUserNameOverride(detail && 'name' in detail ? (detail?.name ?? null) : null) - } - - readStoredName() - window.addEventListener('storage', handleStorage) - window.addEventListener('user-name-updated', handleNameEvent) - return () => { - window.removeEventListener('storage', handleStorage) - window.removeEventListener('user-name-updated', handleNameEvent) - } - }, [userId]) - - React.useEffect(() => { - if (!userId || typeof window === 'undefined') return - - const readStoredAvatar = () => { - const storedVersion = window.localStorage.getItem(`user-avatar-version-${userId}`) - const storedUrl = window.localStorage.getItem(`user-avatar-url-${userId}`) - if (storedVersion || storedUrl !== null) { - setUserAvatarOverride((prev) => ({ - url: storedUrl !== null ? storedUrl || null : prev.url, - version: storedVersion ?? prev.version, - })) - } - } - - const handleStorage = (event: StorageEvent) => { - if (!event.key) return - if ( - event.key === `user-avatar-version-${userId}` || - event.key === `user-avatar-url-${userId}` - ) { - readStoredAvatar() - } - } - - const handleAvatarEvent = (event: Event) => { - const customEvent = event as CustomEvent<{ url?: string | null; version?: number }> - const detail = customEvent.detail - setUserAvatarOverride((prev) => ({ - url: detail && 'url' in detail ? (detail?.url ?? null) : prev.url, - version: - detail && 'version' in detail - ? (detail?.version ?? prev.version ?? Date.now()) - : (prev.version ?? Date.now()), - })) - } - - readStoredAvatar() - window.addEventListener('storage', handleStorage) - window.addEventListener('user-avatar-updated', handleAvatarEvent) - return () => { - window.removeEventListener('storage', handleStorage) - window.removeEventListener('user-avatar-updated', handleAvatarEvent) - } - }, [userId]) - if (shouldShowSkeleton) { return ( @@ -337,7 +245,6 @@ export function GlobalNavbar({ return ( -
@@ -385,7 +292,8 @@ export function GlobalNavbar({ userAvatar={userAvatar} userAvatarVersion={userAvatarVersion} onOpenSettings={openSettings} - systemNavigation={systemNavigation} + canAccessSystemAdmin={isSystemAdmin && navigationMode !== 'admin'} + sidebarTrigger /> @@ -407,6 +315,7 @@ export function GlobalNavbar({ {canManageWorkspaces ? ( (null) - - useEffect(() => { - if (!userId || loadedUserRef.current === userId) return - - loadedUserRef.current = userId - void refetch().then(({ data }) => { - const preferredLocale = data?.preferredLocale - if (!preferredLocale) return - - const { locale, pathname } = stripLocaleFromPathname(window.location.pathname) - if (preferredLocale !== locale) { - replaceLocaleDocument(preferredLocale, `${pathname}${window.location.search}`) - } - }) - }, [refetch, userId]) - - return null -} diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/account/account-settings.test.tsx b/apps/tradinggoose/global-navbar/settings-modal/components/account/account-settings.test.tsx index fdf77b214..ce7927f27 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/components/account/account-settings.test.tsx +++ b/apps/tradinggoose/global-navbar/settings-modal/components/account/account-settings.test.tsx @@ -11,7 +11,6 @@ import type { LocaleCode } from '@/i18n/utils' import { AccountSettings } from './account-settings' const mockUseSession = vi.fn() -const mockUseGeneralSettings = vi.fn() const mockSetTelemetryEnabled = vi.fn() const generalState = { @@ -57,10 +56,6 @@ vi.mock('@/lib/auth-client', () => ({ useSession: () => mockUseSession(), })) -vi.mock('@/hooks/queries/general-settings', () => ({ - useGeneralSettings: () => mockUseGeneralSettings(), -})) - vi.mock('@/stores/settings/general/store', () => ({ useGeneralStore: (selector: (state: typeof generalState) => unknown) => selector(generalState), })) @@ -116,7 +111,6 @@ describe('AccountSettings localization', () => { beforeEach(() => { reactActEnvironment.IS_REACT_ACT_ENVIRONMENT = true mockSetTelemetryEnabled.mockReset() - mockUseGeneralSettings.mockReturnValue({ isPending: false }) mockUseSession.mockReturnValue({ data: { user: { diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/account/account-settings.tsx b/apps/tradinggoose/global-navbar/settings-modal/components/account/account-settings.tsx index a41c9e41b..8aab32685 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/components/account/account-settings.tsx +++ b/apps/tradinggoose/global-navbar/settings-modal/components/account/account-settings.tsx @@ -22,7 +22,6 @@ import { useAuthRedirectUrls } from '@/lib/auth/redirect-urls' import { createLogger } from '@/lib/logs/console/logger' import { useSession } from '@/lib/auth-client' import { useProfilePictureUpload } from '@/global-navbar/settings-modal/components/hooks/use-profile-picture-upload' -import { useGeneralSettings } from '@/hooks/queries/general-settings' import { useGeneralStore } from '@/stores/settings/general/store' const logger = createLogger('AccountSettings') const DEFAULT_AVATAR_SRC = '/profile/avatar.png' @@ -41,12 +40,11 @@ export function AccountSettings() { const userId = session?.user?.id ?? null // Telemetry state from general store - const { isPending: isSettingsPending } = useGeneralSettings() const storeIsLoading = useGeneralStore((state) => state.isLoading) const telemetryEnabled = useGeneralStore((state) => state.telemetryEnabled) const isTelemetryLoading = useGeneralStore((state) => state.isTelemetryLoading) const setTelemetryEnabled = useGeneralStore((state) => state.setTelemetryEnabled) - const isTelemetrySettingsLoading = isSettingsPending || storeIsLoading + const isTelemetrySettingsLoading = storeIsLoading const handleTelemetryToggle = (checked: boolean) => { if (checked === telemetryEnabled || isTelemetryLoading) { diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/help/help-modal.tsx b/apps/tradinggoose/global-navbar/settings-modal/components/help/help-modal.tsx index 28cddac7b..ed580cdca 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/components/help/help-modal.tsx +++ b/apps/tradinggoose/global-navbar/settings-modal/components/help/help-modal.tsx @@ -129,12 +129,14 @@ export function HelpModal({ open, onOpenChange }: HelpModalProps) { useEffect(() => { if (images.length > 0 && scrollContainerRef.current) { const scrollContainer = scrollContainerRef.current - setTimeout(() => { + const timer = setTimeout(() => { scrollContainer.scrollTo({ top: scrollContainer.scrollHeight, behavior: 'smooth', }) }, SCROLL_DELAY_MS) + + return () => clearTimeout(timer) } }, [images.length]) diff --git a/apps/tradinggoose/global-navbar/settings-modal/components/subscription/subscription.tsx b/apps/tradinggoose/global-navbar/settings-modal/components/subscription/subscription.tsx index 520467e62..b0d033df9 100644 --- a/apps/tradinggoose/global-navbar/settings-modal/components/subscription/subscription.tsx +++ b/apps/tradinggoose/global-navbar/settings-modal/components/subscription/subscription.tsx @@ -13,7 +13,7 @@ import { getBillingStatus, getSubscriptionStatus, getUsage } from '@/lib/subscri import type { BillingUpgradeTarget } from '@/lib/subscription/upgrade' import { useSubscriptionUpgrade } from '@/lib/subscription/upgrade' import { cn } from '@/lib/utils' -import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings' +import { useUpdateGeneralSetting } from '@/hooks/queries/general-settings' import { useOrganizationBilling, useOrganizations } from '@/hooks/queries/organization' import { usePublicBillingCatalog } from '@/hooks/queries/public-billing-catalog' import { useSubscriptionData, useUsageLimitData } from '@/hooks/queries/subscription' @@ -181,8 +181,6 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { const [isPrimaryActionPending, setIsPrimaryActionPending] = useState(false) const usageLimitRef = useRef(null) - useGeneralSettings() - const billingPayload = (subscriptionData as any)?.data ?? subscriptionData const organizationBillingPayload = (organizationBillingData as any)?.data ?? organizationBillingData diff --git a/apps/tradinggoose/global-navbar/types.ts b/apps/tradinggoose/global-navbar/types.ts index 39d1031de..1de46f1a2 100644 --- a/apps/tradinggoose/global-navbar/types.ts +++ b/apps/tradinggoose/global-navbar/types.ts @@ -17,7 +17,7 @@ export interface Workspace { name: string ownerId: string billingOwner?: { type: 'user'; userId: string } | { type: 'organization'; organizationId: string } - role?: string + role: 'owner' | 'member' membershipId?: string - permissions?: 'admin' | 'write' | 'read' | null + permissions: 'admin' | 'write' | 'read' } diff --git a/apps/tradinggoose/global-navbar/use-workspace-switcher.test.ts b/apps/tradinggoose/global-navbar/use-workspace-switcher.test.ts index b9e8807a1..b7bb50be5 100644 --- a/apps/tradinggoose/global-navbar/use-workspace-switcher.test.ts +++ b/apps/tradinggoose/global-navbar/use-workspace-switcher.test.ts @@ -5,6 +5,7 @@ import { createRoot, type Root } from 'react-dom/client' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const mockPush = vi.fn() +const mockReplace = vi.fn() let mockSwitchToWorkspace = vi.fn() let fetchMock: ReturnType let originalFetch: typeof globalThis.fetch @@ -29,6 +30,7 @@ afterAll(() => { vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push: mockPush, + replace: mockReplace, }), })) @@ -44,6 +46,7 @@ vi.mock('@/stores/workflows/registry/store', () => ({ describe('useWorkspaceSwitcher', () => { beforeEach(() => { mockPush.mockReset() + mockReplace.mockReset() mockSwitchToWorkspace = vi.fn() latestValue = null @@ -100,6 +103,7 @@ describe('useWorkspaceSwitcher', () => { expect(latestValue.canManageWorkspaces).toBe(true) expect(latestValue.activeWorkspace?.id).toBe('ws-1') expect(fetchMock.mock.calls.map(([url]) => String(url))).toContain('/api/workspaces') + expect(mockReplace).not.toHaveBeenCalled() await act(async () => { latestValue.setWorkspaceMenuOpen(true) @@ -115,4 +119,26 @@ describe('useWorkspaceSwitcher', () => { expect(latestValue.inviteDialogOpen).toBe(true) expect(latestValue.deleteDialogOpen).toBe(true) }) + + it('does not redirect during the workspace bootstrap fetch (server owns the root redirect)', async () => { + const { useWorkspaceSwitcher } = await import('@/global-navbar/use-workspace-switcher') + + function Harness() { + latestValue = useWorkspaceSwitcher({ + enabled: true, + section: 'dashboard', + }) + return null + } + + await act(async () => { + root?.render(React.createElement(Harness)) + await flush() + }) + + expect(fetchMock.mock.calls.map(([url]) => String(url))).toContain('/api/workspaces') + expect(latestValue.activeWorkspace?.id).toBe('ws-1') + expect(mockReplace).not.toHaveBeenCalled() + expect(mockPush).not.toHaveBeenCalled() + }) }) diff --git a/apps/tradinggoose/global-navbar/use-workspace-switcher.ts b/apps/tradinggoose/global-navbar/use-workspace-switcher.ts index 124878bac..5dd71e3f0 100644 --- a/apps/tradinggoose/global-navbar/use-workspace-switcher.ts +++ b/apps/tradinggoose/global-navbar/use-workspace-switcher.ts @@ -18,7 +18,7 @@ export function useWorkspaceSwitcher({ workspaceId, section, }: UseWorkspaceSwitcherOptions) { - const router = useRouter() + const { push } = useRouter() const switchToWorkspace = useWorkflowRegistry((state) => state.switchToWorkspace) const canManageWorkspaces = true const [workspaces, setWorkspaces] = React.useState([]) @@ -55,20 +55,19 @@ export function useWorkspaceSwitcher({ return } - const data = await response.json() - const items = ((data.workspaces ?? []) as Workspace[]).map((workspace) => ({ - ...workspace, - permissions: workspace.permissions ?? 'admin', - role: workspace.role ?? (workspace.permissions === 'admin' ? 'owner' : 'member'), - })) + const data = (await response.json()) as { workspaces?: Workspace[] } + const items = data.workspaces ?? [] setWorkspaces(items) + const firstWorkspace = items[0] ?? null + if (workspaceId) { - const match = items.find((workspace) => workspace.id === workspaceId) - setActiveWorkspace(match ?? items[0] ?? null) + setActiveWorkspace( + items.find((workspace) => workspace.id === workspaceId) ?? firstWorkspace + ) } else { - setActiveWorkspace((current) => current ?? items[0] ?? null) + setActiveWorkspace((current) => current ?? firstWorkspace) } } catch (error) { console.error('Error fetching workspaces:', error) @@ -100,9 +99,9 @@ export function useWorkspaceSwitcher({ } } - router.push(getWorkspaceSwitchPath(workspace.id, section)) + push(getWorkspaceSwitchPath(workspace.id, section)) }, - [router, section, switchToWorkspace, workspaceId] + [push, section, switchToWorkspace, workspaceId] ) const handleCreateWorkspace = React.useCallback(async () => { @@ -128,15 +127,11 @@ export function useWorkspaceSwitcher({ throw new Error(error?.error ?? 'Failed to create workspace') } - const data = await response.json() + const data = (await response.json()) as { workspace?: Workspace } await fetchWorkspaces() if (data.workspace) { - await handleSwitchWorkspace({ - ...data.workspace, - permissions: data.workspace.permissions ?? 'admin', - role: data.workspace.role ?? 'owner', - } satisfies Workspace) + await handleSwitchWorkspace(data.workspace) } } catch (error) { console.error('Error creating workspace:', error) diff --git a/apps/tradinggoose/hooks/queries/api-keys.ts b/apps/tradinggoose/hooks/queries/api-keys.ts index 2daf9fb1d..165676985 100644 --- a/apps/tradinggoose/hooks/queries/api-keys.ts +++ b/apps/tradinggoose/hooks/queries/api-keys.ts @@ -17,7 +17,7 @@ export const apiKeysKeys = { export interface ApiKey { id: string name: string - key: string + key?: string displayKey?: string lastUsed?: string createdAt: string diff --git a/apps/tradinggoose/hooks/queries/custom-tools.ts b/apps/tradinggoose/hooks/queries/custom-tools.ts index 214f38bc3..e053cf5be 100644 --- a/apps/tradinggoose/hooks/queries/custom-tools.ts +++ b/apps/tradinggoose/hooks/queries/custom-tools.ts @@ -33,7 +33,11 @@ type ApiCustomTool = Partial & { } function normalizeCustomTool(tool: ApiCustomTool, workspaceId: string): CustomToolDefinition { - const functionName = tool.schema.function?.name || tool.id + const title = tool.title.trim() + if (!title) { + throw new Error('Custom tool title is required') + } + const parameters = tool.schema.function?.parameters ?? { type: 'object', properties: {}, @@ -41,21 +45,15 @@ function normalizeCustomTool(tool: ApiCustomTool, workspaceId: string): CustomTo return { id: tool.id, - title: tool.title, + title, code: typeof tool.code === 'string' ? tool.code : '', workspaceId: tool.workspaceId ?? workspaceId, userId: tool.userId ?? null, - createdAt: - typeof tool.createdAt === 'string' - ? tool.createdAt - : tool.updatedAt && typeof tool.updatedAt === 'string' - ? tool.updatedAt - : new Date().toISOString(), + createdAt: typeof tool.createdAt === 'string' ? tool.createdAt : undefined, updatedAt: typeof tool.updatedAt === 'string' ? tool.updatedAt : undefined, schema: { type: tool.schema.type ?? 'function', function: { - name: functionName, description: tool.schema.function?.description, parameters: { type: parameters.type ?? 'object', @@ -99,7 +97,7 @@ async function fetchCustomTools(workspaceId: string): Promise { const updatedAt = typeof tool.updatedAt === 'string' ? tool.updatedAt : (tool.createdAt ?? '') - return `${tool.id}:${updatedAt}:${tool.title}:${tool.schema?.function?.name ?? ''}:${tool.code ?? ''}` + return `${tool.id}:${updatedAt}:${tool.title}:${JSON.stringify(tool.schema?.function ?? {})}:${tool.code ?? ''}` }) .join('|') @@ -317,36 +315,6 @@ export function useUpdateCustomTool() { logger.info(`Updated custom tool: ${toolId}`) return data.data }, - onMutate: async ({ workspaceId, toolId, updates }) => { - await queryClient.cancelQueries({ queryKey: customToolsKeys.list(workspaceId) }) - - const previousTools = queryClient.getQueryData( - customToolsKeys.list(workspaceId) - ) - - if (previousTools) { - queryClient.setQueryData( - customToolsKeys.list(workspaceId), - previousTools.map((tool) => - tool.id === toolId - ? { - ...tool, - title: updates.title ?? tool.title, - schema: updates.schema ?? tool.schema, - code: updates.code ?? tool.code, - } - : tool - ) - ) - } - - return { previousTools } - }, - onError: (_err, variables, context) => { - if (context?.previousTools) { - queryClient.setQueryData(customToolsKeys.list(variables.workspaceId), context.previousTools) - } - }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: customToolsKeys.list(variables.workspaceId) }) }, @@ -383,28 +351,7 @@ export function useDeleteCustomTool() { logger.info(`Deleted custom tool: ${toolId}`) return data }, - onMutate: async ({ workspaceId, toolId }) => { - await queryClient.cancelQueries({ queryKey: customToolsKeys.list(workspaceId) }) - - const previousTools = queryClient.getQueryData( - customToolsKeys.list(workspaceId) - ) - - if (previousTools) { - queryClient.setQueryData( - customToolsKeys.list(workspaceId), - previousTools.filter((tool) => tool.id !== toolId) - ) - } - - return { previousTools, workspaceId } - }, - onError: (_err, _variables, context) => { - if (context?.previousTools && context?.workspaceId) { - queryClient.setQueryData(customToolsKeys.list(context.workspaceId), context.previousTools) - } - }, - onSettled: (_data, _error, variables) => { + onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: customToolsKeys.list(variables.workspaceId) }) }, }) diff --git a/apps/tradinggoose/hooks/queries/environment.ts b/apps/tradinggoose/hooks/queries/environment.ts index 47a1ea760..480fc45d5 100644 --- a/apps/tradinggoose/hooks/queries/environment.ts +++ b/apps/tradinggoose/hooks/queries/environment.ts @@ -1,8 +1,10 @@ import { useEffect } from 'react' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { handleAuthError } from '@/lib/auth/auth-error-handler' import type { WorkspaceEnvironmentData } from '@/lib/environment/api' import { fetchPersonalEnvironment, fetchWorkspaceEnvironment } from '@/lib/environment/api' import { createLogger } from '@/lib/logs/console/logger' +import { usePathname } from '@/i18n/navigation' import { API_ENDPOINTS } from '@/stores/constants' import { useEnvironmentStore } from '@/stores/settings/environment/store' @@ -16,12 +18,26 @@ export const environmentKeys = { export type { WorkspaceEnvironmentData } from '@/lib/environment/api' +async function throwEnvironmentResponseError( + response: Response, + reason: string, + callbackPathname: string, + message: string +): Promise { + if (response.status === 401) { + await handleAuthError(reason, callbackPathname) + } + + throw new Error(`${message}: ${response.statusText}`) +} + export function usePersonalEnvironment() { + const pathname = usePathname() const setVariables = useEnvironmentStore((state) => state.setVariables) const query = useQuery({ queryKey: environmentKeys.personal(), - queryFn: fetchPersonalEnvironment, + queryFn: () => fetchPersonalEnvironment(pathname), staleTime: 60 * 1000, placeholderData: keepPreviousData, }) @@ -39,9 +55,11 @@ export function useWorkspaceEnvironment( workspaceId: string, options?: { select?: (data: WorkspaceEnvironmentData) => TData } ) { + const pathname = usePathname() + return useQuery({ queryKey: environmentKeys.workspace(workspaceId), - queryFn: () => fetchWorkspaceEnvironment(workspaceId), + queryFn: () => fetchWorkspaceEnvironment(workspaceId, pathname), enabled: Boolean(workspaceId), staleTime: 60 * 1000, placeholderData: keepPreviousData, @@ -56,6 +74,7 @@ interface UpsertPersonalEnvironmentParams { export function useUpsertPersonalEnvironment() { const queryClient = useQueryClient() + const pathname = usePathname() return useMutation({ mutationFn: async ({ key, value }: UpsertPersonalEnvironmentParams) => { @@ -66,7 +85,12 @@ export function useUpsertPersonalEnvironment() { }) if (!response.ok) { - throw new Error(`Failed to update personal environment variable: ${response.statusText}`) + await throwEnvironmentResponseError( + response, + 'environment-query:upsert-personal', + pathname, + 'Failed to update personal environment variable' + ) } logger.info(`Upserted personal environment variable: ${key}`) @@ -85,6 +109,7 @@ interface RemovePersonalEnvironmentParams { export function useRemovePersonalEnvironment() { const queryClient = useQueryClient() + const pathname = usePathname() return useMutation({ mutationFn: async ({ key }: RemovePersonalEnvironmentParams) => { @@ -95,7 +120,12 @@ export function useRemovePersonalEnvironment() { }) if (!response.ok) { - throw new Error(`Failed to remove personal environment variable: ${response.statusText}`) + await throwEnvironmentResponseError( + response, + 'environment-query:remove-personal', + pathname, + 'Failed to remove personal environment variable' + ) } logger.info(`Removed personal environment variable: ${key}`) @@ -115,6 +145,7 @@ interface UpsertWorkspaceEnvironmentParams { export function useUpsertWorkspaceEnvironment() { const queryClient = useQueryClient() + const pathname = usePathname() return useMutation({ mutationFn: async ({ workspaceId, variables }: UpsertWorkspaceEnvironmentParams) => { @@ -125,7 +156,12 @@ export function useUpsertWorkspaceEnvironment() { }) if (!response.ok) { - throw new Error(`Failed to update workspace environment: ${response.statusText}`) + await throwEnvironmentResponseError( + response, + 'environment-query:upsert-workspace', + pathname, + 'Failed to update workspace environment' + ) } logger.info(`Upserted workspace environment variables for workspace: ${workspaceId}`) @@ -147,6 +183,7 @@ interface RemoveWorkspaceEnvironmentParams { export function useRemoveWorkspaceEnvironment() { const queryClient = useQueryClient() + const pathname = usePathname() return useMutation({ mutationFn: async ({ workspaceId, keys }: RemoveWorkspaceEnvironmentParams) => { @@ -157,7 +194,12 @@ export function useRemoveWorkspaceEnvironment() { }) if (!response.ok) { - throw new Error(`Failed to remove workspace environment keys: ${response.statusText}`) + await throwEnvironmentResponseError( + response, + 'environment-query:remove-workspace', + pathname, + 'Failed to remove workspace environment keys' + ) } logger.info(`Removed ${keys.length} workspace environment keys for workspace: ${workspaceId}`) diff --git a/apps/tradinggoose/hooks/queries/general-settings.ts b/apps/tradinggoose/hooks/queries/general-settings.ts index d31f1d594..dfafd6b1d 100644 --- a/apps/tradinggoose/hooks/queries/general-settings.ts +++ b/apps/tradinggoose/hooks/queries/general-settings.ts @@ -1,18 +1,20 @@ import { useEffect } from 'react' -import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' +import { defaultLocale, isLocaleCode, type LocaleCode } from '@/i18n/utils' import { useGeneralStore } from '@/stores/settings/general/store' const logger = createLogger('GeneralSettingsQuery') export const generalSettingsKeys = { all: ['generalSettings'] as const, - settings: () => [...generalSettingsKeys.all, 'settings'] as const, + settings: (userId: string | null) => [...generalSettingsKeys.all, 'settings', userId] as const, } export interface GeneralSettings { theme: 'light' | 'dark' | 'system' - preferredLocale: 'en' | 'es' | 'zh' + preferredLocale: LocaleCode telemetryEnabled: boolean billingUsageNotificationsEnabled: boolean } @@ -28,7 +30,7 @@ async function fetchGeneralSettings(): Promise { return { theme: data.theme || 'system', - preferredLocale: data.preferredLocale || 'en', + preferredLocale: isLocaleCode(data.preferredLocale) ? data.preferredLocale : defaultLocale, telemetryEnabled: data.telemetryEnabled ?? true, billingUsageNotificationsEnabled: data.billingUsageNotificationsEnabled ?? true, } @@ -39,25 +41,30 @@ function syncSettingsToZustand(settings: GeneralSettings) { setSettings({ theme: settings.theme, - preferredLocale: settings.preferredLocale, telemetryEnabled: settings.telemetryEnabled, isBillingUsageNotificationsEnabled: settings.billingUsageNotificationsEnabled, }) } -export function useGeneralSettings() { +export function useGeneralSettings({ + enabled = true, + userId, +}: { + enabled?: boolean + userId: string | null +}) { const query = useQuery({ - queryKey: generalSettingsKeys.settings(), + queryKey: generalSettingsKeys.settings(userId), queryFn: fetchGeneralSettings, + enabled: enabled && Boolean(userId), staleTime: 60 * 60 * 1000, - placeholderData: keepPreviousData, }) useEffect(() => { - if (query.data) { + if (userId && query.data) { syncSettingsToZustand(query.data) } - }, [query.data]) + }, [query.data, userId]) return query } @@ -69,6 +76,9 @@ interface UpdateSettingParams { export function useUpdateGeneralSetting() { const queryClient = useQueryClient() + const { data: session } = useSession() + const userId = session?.user?.id ?? null + const settingsKey = generalSettingsKeys.settings(userId) return useMutation({ mutationFn: async ({ key, value }: UpdateSettingParams) => { @@ -85,18 +95,20 @@ export function useUpdateGeneralSetting() { return response.json() }, onMutate: async ({ key, value }) => { - await queryClient.cancelQueries({ queryKey: generalSettingsKeys.settings() }) + if (!userId) { + return { previousSettings: undefined } + } + + await queryClient.cancelQueries({ queryKey: settingsKey }) - const previousSettings = queryClient.getQueryData( - generalSettingsKeys.settings() - ) + const previousSettings = queryClient.getQueryData(settingsKey) if (previousSettings) { const newSettings = { ...previousSettings, [key]: value, } - queryClient.setQueryData(generalSettingsKeys.settings(), newSettings) + queryClient.setQueryData(settingsKey, newSettings) syncSettingsToZustand(newSettings) } @@ -104,13 +116,13 @@ export function useUpdateGeneralSetting() { }, onError: (err, _variables, context) => { if (context?.previousSettings) { - queryClient.setQueryData(generalSettingsKeys.settings(), context.previousSettings) + queryClient.setQueryData(settingsKey, context.previousSettings) syncSettingsToZustand(context.previousSettings) } logger.error('Failed to update setting:', err) }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: generalSettingsKeys.settings() }) + queryClient.invalidateQueries({ queryKey: settingsKey }) }, }) } diff --git a/apps/tradinggoose/hooks/queries/indicators.ts b/apps/tradinggoose/hooks/queries/indicators.ts index 366bddedf..d2cc4400e 100644 --- a/apps/tradinggoose/hooks/queries/indicators.ts +++ b/apps/tradinggoose/hooks/queries/indicators.ts @@ -36,12 +36,7 @@ function normalizeIndicator(indicator: ApiIndicator, workspaceId: string): Indic indicator.inputMeta && typeof indicator.inputMeta === 'object' ? (indicator.inputMeta as InputMetaMap) : undefined, - createdAt: - typeof indicator.createdAt === 'string' - ? indicator.createdAt - : indicator.updatedAt && typeof indicator.updatedAt === 'string' - ? indicator.updatedAt - : new Date().toISOString(), + createdAt: typeof indicator.createdAt === 'string' ? indicator.createdAt : undefined, updatedAt: typeof indicator.updatedAt === 'string' ? indicator.updatedAt : undefined, } } @@ -142,7 +137,7 @@ export function useIndicators(workspaceId: string) { interface CreateIndicatorParams { workspaceId: string - indicator: Omit + indicator: Pick } export function useCreateIndicator() { @@ -152,19 +147,11 @@ export function useCreateIndicator() { mutationFn: async ({ workspaceId, indicator }: CreateIndicatorParams) => { logger.info(`Creating indicator: ${indicator.name} in workspace ${workspaceId}`) - const resolvedIndicator = { - ...indicator, - color: - typeof indicator.color === 'string' && indicator.color.trim().length > 0 - ? indicator.color.trim() - : undefined, - } - const response = await fetch(API_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - indicators: [resolvedIndicator], + indicators: [indicator], workspaceId, }), }) @@ -191,9 +178,7 @@ export function useCreateIndicator() { interface UpdateIndicatorParams { workspaceId: string indicatorId: string - updates: Partial< - Omit - > + updates: Partial> } interface ImportIndicatorsParams { @@ -217,10 +202,6 @@ export function useUpdateIndicator() { throw new Error('Indicator not found') } - const resolvedInputMeta = Object.hasOwn(updates, 'inputMeta') - ? updates.inputMeta - : currentIndicator.inputMeta - const response = await fetch(API_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -229,9 +210,7 @@ export function useUpdateIndicator() { { id: indicatorId, name: updates.name ?? currentIndicator.name, - color: updates.color ?? currentIndicator.color, pineCode: updates.pineCode ?? currentIndicator.pineCode, - inputMeta: resolvedInputMeta, }, ], workspaceId, @@ -251,37 +230,6 @@ export function useUpdateIndicator() { logger.info(`Updated indicator: ${indicatorId}`) return data.data }, - onMutate: async ({ workspaceId, indicatorId, updates }) => { - await queryClient.cancelQueries({ queryKey: indicatorKeys.list(workspaceId) }) - - const previousIndicators = queryClient.getQueryData( - indicatorKeys.list(workspaceId) - ) - - if (previousIndicators) { - queryClient.setQueryData( - indicatorKeys.list(workspaceId), - previousIndicators.map((indicator) => - indicator.id === indicatorId - ? { - ...indicator, - ...updates, - } - : indicator - ) - ) - } - - return { previousIndicators } - }, - onError: (_err, variables, context) => { - if (context?.previousIndicators) { - queryClient.setQueryData( - indicatorKeys.list(variables.workspaceId), - context.previousIndicators - ) - } - }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: indicatorKeys.list(variables.workspaceId) }) }, diff --git a/apps/tradinggoose/hooks/queries/skills.test.tsx b/apps/tradinggoose/hooks/queries/skills.test.tsx deleted file mode 100644 index 2141472b3..000000000 --- a/apps/tradinggoose/hooks/queries/skills.test.tsx +++ /dev/null @@ -1,211 +0,0 @@ -/** - * @vitest-environment jsdom - */ - -import { act } from 'react' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { useSkills } from '@/hooks/queries/skills' -import { useSkillsStore } from '@/stores/skills/store' - -interface ApiSkill { - id: string - name: string - description: string - content: string - workspaceId?: string - userId?: string | null - createdAt?: string - updatedAt?: string -} - -interface PendingRequest { - resolve: (response: { ok: boolean; json: () => Promise<{ data: ApiSkill[] }> }) => void -} - -function SkillsHarness({ workspaceId }: { workspaceId: string }) { - useSkills(workspaceId) - return null -} - -function createSkill(overrides: Partial): ApiSkill { - return { - id: 'skill-1', - name: 'market-research', - description: 'Research the market before taking action.', - content: 'Investigate the market and summarize the setup.', - ...overrides, - } -} - -describe('useSkills', () => { - let container: HTMLDivElement - let root: Root - let queryClient: QueryClient - const pendingRequests = new Map() - - const flushAsyncWork = async () => { - await Promise.resolve() - await Promise.resolve() - await new Promise((resolve) => setTimeout(resolve, 0)) - } - - const waitFor = async (predicate: () => boolean, timeoutMs = 1000) => { - const startTime = Date.now() - - while (!predicate()) { - if (Date.now() - startTime > timeoutMs) { - throw new Error('Timed out waiting for condition') - } - - await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 10)) - }) - } - } - - const renderHarness = async (workspaceId: string) => { - act(() => { - root.render( - - - - ) - }) - - await act(async () => { - await flushAsyncWork() - }) - } - - const resolveWorkspaceRequest = async (workspaceId: string, data: ApiSkill[]) => { - const queue = pendingRequests.get(workspaceId) - const nextRequest = queue?.shift() - - if (!nextRequest) { - throw new Error(`No pending request for workspace ${workspaceId}`) - } - - if (queue && queue.length === 0) { - pendingRequests.delete(workspaceId) - } - - await act(async () => { - nextRequest.resolve({ - ok: true, - json: async () => ({ data }), - }) - await flushAsyncWork() - }) - } - - beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - gcTime: 0, - }, - }, - }) - - useSkillsStore.getState().resetAll() - pendingRequests.clear() - ;( - globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } - ).IS_REACT_ACT_ENVIRONMENT = true - - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) - - global.fetch = vi.fn((input) => { - const rawUrl = - typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url - const url = new URL(rawUrl, 'http://localhost') - const workspaceId = url.searchParams.get('workspaceId') - - if (!workspaceId) { - throw new Error('Missing workspaceId in fetch mock') - } - - return new Promise((resolve) => { - const queue = pendingRequests.get(workspaceId) ?? [] - queue.push({ - resolve: resolve as PendingRequest['resolve'], - }) - pendingRequests.set(workspaceId, queue) - }) - }) as typeof fetch - }) - - afterEach(() => { - act(() => { - root.unmount() - }) - queryClient.clear() - container.remove() - useSkillsStore.getState().resetAll() - vi.restoreAllMocks() - }) - - it('does not sync previous-workspace data into the next workspace bucket while fetching', async () => { - await renderHarness('workspace-a') - await resolveWorkspaceRequest('workspace-a', [ - createSkill({ - id: 'skill-a', - name: 'alpha', - description: 'Alpha skill', - content: 'Alpha instructions', - }), - ]) - - await waitFor(() => useSkillsStore.getState().getAllSkills('workspace-a').length === 1) - - await renderHarness('workspace-b') - - expect(useSkillsStore.getState().getAllSkills('workspace-b')).toEqual([]) - - await resolveWorkspaceRequest('workspace-b', [ - createSkill({ - id: 'skill-b', - name: 'beta', - description: 'Beta skill', - content: 'Beta instructions', - }), - ]) - - await waitFor(() => useSkillsStore.getState().getAllSkills('workspace-b').length === 1) - - expect( - useSkillsStore - .getState() - .getAllSkills('workspace-b') - .map((skill) => skill.name) - ).toEqual(['beta']) - }) - - it('syncs the new workspace result even when the skill payload matches the previous workspace', async () => { - const sharedSkill = createSkill({ - id: 'shared-skill', - name: 'shared-skill', - description: 'Shared skill', - content: 'Shared instructions', - }) - - await renderHarness('workspace-a') - await resolveWorkspaceRequest('workspace-a', [sharedSkill]) - - await renderHarness('workspace-b') - - await resolveWorkspaceRequest('workspace-b', [sharedSkill]) - - await waitFor(() => useSkillsStore.getState().getAllSkills('workspace-b').length === 1) - - const workspaceBSkills = useSkillsStore.getState().getAllSkills('workspace-b') - expect(workspaceBSkills).toHaveLength(1) - expect(workspaceBSkills[0]?.workspaceId).toBe('workspace-b') - expect(workspaceBSkills[0]?.name).toBe('shared-skill') - }) -}) diff --git a/apps/tradinggoose/hooks/queries/skills.ts b/apps/tradinggoose/hooks/queries/skills.ts index 551394f0c..e38b01386 100644 --- a/apps/tradinggoose/hooks/queries/skills.ts +++ b/apps/tradinggoose/hooks/queries/skills.ts @@ -1,9 +1,7 @@ -import { useEffect, useRef } from 'react' -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { createLogger } from '@/lib/logs/console/logger' import { type ImportedSkillTransferRecord, SKILL_NAME_MAX_LENGTH } from '@/lib/skills/import-export' -import { useSkillsStore } from '@/stores/skills/store' -import type { SkillDefinition } from '@/stores/skills/types' +import type { SkillDefinition } from '@/lib/skills/types' const logger = createLogger('SkillsQueries') const API_ENDPOINT = '/api/skills' @@ -41,22 +39,14 @@ function normalizeSkill( name: rawSkill.name, description: rawSkill.description, content: rawSkill.content, - createdAt: - typeof rawSkill.createdAt === 'string' - ? rawSkill.createdAt - : rawSkill.updatedAt && typeof rawSkill.updatedAt === 'string' - ? rawSkill.updatedAt - : new Date().toISOString(), + createdAt: typeof rawSkill.createdAt === 'string' ? rawSkill.createdAt : undefined, updatedAt: typeof rawSkill.updatedAt === 'string' ? rawSkill.updatedAt : undefined, } } -function syncSkillsToStore(workspaceId: string, skills: SkillDefinition[]) { - useSkillsStore.getState().setSkills(workspaceId, skills) -} - -async function fetchSkills(workspaceId: string): Promise { - const response = await fetch(`${API_ENDPOINT}?workspaceId=${workspaceId}`) +export async function fetchSkills(workspaceId: string): Promise { + const params = new URLSearchParams({ workspaceId }) + const response = await fetch(`${API_ENDPOINT}?${params}`) if (!response.ok) { const errorData = await response.json().catch(() => ({})) @@ -102,43 +92,6 @@ async function fetchSkills(workspaceId: string): Promise { return normalizedSkills } -export function useSkills(workspaceId: string) { - const query = useQuery({ - queryKey: skillsKeys.list(workspaceId), - queryFn: () => fetchSkills(workspaceId), - enabled: !!workspaceId, - staleTime: 60 * 1000, - }) - - const lastSyncRef = useRef('') - - useEffect(() => { - lastSyncRef.current = '' - }, [workspaceId]) - - useEffect(() => { - if (!workspaceId || !query.data) return - - const signature = query.data - .map((skill) => { - const updatedAt = - typeof skill.updatedAt === 'string' ? skill.updatedAt : (skill.createdAt ?? '') - return `${skill.id}:${updatedAt}:${skill.name}:${skill.description}:${skill.content}` - }) - .join('|') - const syncKey = `${workspaceId}:${signature}` - - if (syncKey === lastSyncRef.current) { - return - } - - lastSyncRef.current = syncKey - syncSkillsToStore(workspaceId, query.data) - }, [query.data, workspaceId]) - - return query -} - interface CreateSkillParams { workspaceId: string skill: { @@ -274,36 +227,6 @@ export function useUpdateSkill() { return data.data }, - onMutate: async ({ workspaceId, skillId, updates }) => { - await queryClient.cancelQueries({ queryKey: skillsKeys.list(workspaceId) }) - - const previousSkills = queryClient.getQueryData( - skillsKeys.list(workspaceId) - ) - - if (previousSkills) { - queryClient.setQueryData( - skillsKeys.list(workspaceId), - previousSkills.map((skill) => - skill.id === skillId - ? { - ...skill, - name: updates.name ?? skill.name, - description: updates.description ?? skill.description, - content: updates.content ?? skill.content, - } - : skill - ) - ) - } - - return { previousSkills } - }, - onError: (_err, variables, context) => { - if (context?.previousSkills) { - queryClient.setQueryData(skillsKeys.list(variables.workspaceId), context.previousSkills) - } - }, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: skillsKeys.list(variables.workspaceId) }) }, @@ -335,28 +258,7 @@ export function useDeleteSkill() { return data }, - onMutate: async ({ workspaceId, skillId }) => { - await queryClient.cancelQueries({ queryKey: skillsKeys.list(workspaceId) }) - - const previousSkills = queryClient.getQueryData( - skillsKeys.list(workspaceId) - ) - - if (previousSkills) { - queryClient.setQueryData( - skillsKeys.list(workspaceId), - previousSkills.filter((skill) => skill.id !== skillId) - ) - } - - return { previousSkills, workspaceId } - }, - onError: (_err, _variables, context) => { - if (context?.previousSkills && context?.workspaceId) { - queryClient.setQueryData(skillsKeys.list(context.workspaceId), context.previousSkills) - } - }, - onSettled: (_data, _error, variables) => { + onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: skillsKeys.list(variables.workspaceId) }) }, }) diff --git a/apps/tradinggoose/hooks/queries/templates.ts b/apps/tradinggoose/hooks/queries/templates.ts deleted file mode 100644 index 1b3824a24..000000000 --- a/apps/tradinggoose/hooks/queries/templates.ts +++ /dev/null @@ -1,396 +0,0 @@ -import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { createLogger } from '@/lib/logs/console/logger' - -const logger = createLogger('TemplateQueries') - -export const templateKeys = { - all: ['templates'] as const, - lists: () => [...templateKeys.all, 'list'] as const, - list: (filters?: TemplateListFilters) => [...templateKeys.lists(), filters ?? {}] as const, - details: () => [...templateKeys.all, 'detail'] as const, - detail: (templateId?: string) => [...templateKeys.details(), templateId ?? ''] as const, - byWorkflow: (workflowId?: string) => - [...templateKeys.all, 'byWorkflow', workflowId ?? ''] as const, -} - -export interface TemplateListFilters { - search?: string - status?: 'pending' | 'approved' | 'rejected' - workflowId?: string - limit?: number - offset?: number - includeAllStatuses?: boolean -} - -export interface TemplateCreator { - id: string - name: string - referenceType: 'user' | 'organization' - referenceId: string - email?: string - website?: string - profileImageUrl?: string | null - details?: { - about?: string - xUrl?: string - linkedinUrl?: string - websiteUrl?: string - contactEmail?: string - } | null - createdAt: string - updatedAt: string -} - -export interface Template { - id: string - workflowId: string - name: string - details?: { - tagline?: string - about?: string - } - creatorId?: string - creator?: TemplateCreator - views: number - stars: number - status: 'pending' | 'approved' | 'rejected' - tags: string[] - requiredCredentials: Record - state: any - createdAt: string - updatedAt: string - isStarred?: boolean - isSuperUser?: boolean -} - -export interface TemplatesResponse { - data: Template[] - pagination: { - total: number - limit: number - offset: number - page: number - totalPages: number - } -} - -export interface TemplateDetailResponse { - data: Template -} - -export interface CreateTemplateInput { - workflowId: string - name: string - details?: { - tagline?: string - about?: string - } - creatorId?: string - tags?: string[] -} - -export interface UpdateTemplateInput { - name?: string - details?: { - tagline?: string - about?: string - } - creatorId?: string - tags?: string[] - updateState?: boolean -} - -async function fetchTemplates(filters?: TemplateListFilters): Promise { - const params = new URLSearchParams() - - if (filters?.search) params.set('search', filters.search) - if (filters?.status) params.set('status', filters.status) - if (filters?.workflowId) params.set('workflowId', filters.workflowId) - if (filters?.includeAllStatuses) params.set('includeAllStatuses', 'true') - params.set('limit', (filters?.limit ?? 50).toString()) - params.set('offset', (filters?.offset ?? 0).toString()) - - const response = await fetch(`/api/templates?${params.toString()}`) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.error || 'Failed to fetch templates') - } - - return response.json() -} - -async function fetchTemplate(templateId: string): Promise { - const response = await fetch(`/api/templates/${templateId}`) - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})) - throw new Error(errorData.error || 'Failed to fetch template') - } - - return response.json() -} - -async function fetchTemplateByWorkflow(workflowId: string): Promise