diff --git a/dashboards/tracker-web/src/__tests__/agent-proxy.test.ts b/dashboards/tracker-web/src/__tests__/agent-proxy.test.ts new file mode 100644 index 00000000..526b94a1 --- /dev/null +++ b/dashboards/tracker-web/src/__tests__/agent-proxy.test.ts @@ -0,0 +1,96 @@ +/** + * Tests for /api/agent/v1/[...path] — agent-facing platform proxy. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { NextRequest } from 'next/server'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +import { GET, POST } from '@/app/api/agent/v1/[...path]/route'; + +function mockNextRequest( + method: string, + path: string, + body?: string, + headers?: Record +): NextRequest { + const headerMap = new Map(Object.entries(headers || {})); + return { + method, + headers: { + get: (key: string) => headerMap.get(key.toLowerCase()) || headerMap.get(key) || null, + }, + nextUrl: { + searchParams: new URLSearchParams('limit=10'), + }, + text: async () => body || '', + } as unknown as NextRequest; +} + +describe('agent v1 proxy', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.clearAllMocks(); + process.env.PLATFORM_API_URL = 'http://localhost:4003'; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('proxies agent list requests with product and agent key headers', async () => { + mockFetch.mockResolvedValue({ status: 200, text: async () => JSON.stringify({ items: [] }) }); + + const req = mockNextRequest('GET', 'items/assigned', undefined, { + 'x-agent-key': 'agent-key-1', + 'x-product-id': 'tracker', + }); + const res = await GET(req, { params: Promise.resolve({ path: ['items', 'assigned'] }) }); + + expect(res.status).toBe(200); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/api/agent/v1/items/assigned?limit=10'), + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'x-agent-key': 'agent-key-1', + 'x-product-id': 'tracker', + }), + }) + ); + }); + + it('proxies agent mutation requests with JSON body', async () => { + mockFetch.mockResolvedValue({ + status: 201, + text: async () => JSON.stringify({ id: 'item_1' }), + }); + const body = JSON.stringify({ title: 'Agent-created item' }); + const req = mockNextRequest('POST', 'items', body, { authorization: 'Bearer token' }); + + const res = await POST(req, { params: Promise.resolve({ path: ['items'] }) }); + + expect(res.status).toBe(201); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/api/agent/v1/items'), + expect.objectContaining({ + method: 'POST', + body, + headers: expect.objectContaining({ Authorization: 'Bearer token' }), + }) + ); + }); + + it('returns 502 when the agent API is unavailable', async () => { + mockFetch.mockRejectedValue(new Error('ECONNREFUSED')); + + const req = mockNextRequest('GET', 'items/assigned'); + const res = await GET(req, { params: Promise.resolve({ path: ['items', 'assigned'] }) }); + + expect(res.status).toBe(502); + await expect(res.json()).resolves.toEqual({ error: 'Agent API unavailable' }); + }); +}); diff --git a/dashboards/tracker-web/src/app/api/agent/v1/[...path]/route.ts b/dashboards/tracker-web/src/app/api/agent/v1/[...path]/route.ts new file mode 100644 index 00000000..b4d5af80 --- /dev/null +++ b/dashboards/tracker-web/src/app/api/agent/v1/[...path]/route.ts @@ -0,0 +1,58 @@ +/** + * Agent API v1 proxy. + * + * Exposes /api/agent/v1/* from tracker-web and forwards it to platform-service. + * This gives coding agents a stable product-facing surface while the backend + * owns auth, claim/close semantics, and persistence. + */ + +import { NextRequest, NextResponse } from 'next/server'; + +const PLATFORM_API = process.env.PLATFORM_API_URL || 'http://localhost:4003'; + +async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { + const { path } = await params; + const targetPath = `/api/agent/v1/${path.join('/')}`; + const url = new URL(targetPath, PLATFORM_API); + + req.nextUrl.searchParams.forEach((value, key) => { + url.searchParams.set(key, value); + }); + + try { + const headers: Record = { 'Content-Type': 'application/json' }; + const auth = req.headers.get('authorization'); + const agentKey = req.headers.get('x-agent-key'); + const productId = req.headers.get('x-product-id'); + + if (auth) headers.Authorization = auth; + if (agentKey) headers['x-agent-key'] = agentKey; + if (productId) headers['x-product-id'] = productId; + + const fetchOptions: RequestInit = { + method: req.method, + headers, + }; + + if (req.method !== 'GET' && req.method !== 'HEAD') { + const body = await req.text(); + if (body) fetchOptions.body = body; + } + + const res = await fetch(url.toString(), fetchOptions); + const data = await res.text(); + + return new NextResponse(data, { + status: res.status, + headers: { 'Content-Type': res.headers?.get('content-type') || 'application/json' }, + }); + } catch { + return NextResponse.json({ error: 'Agent API unavailable' }, { status: 502 }); + } +} + +export const GET = proxy; +export const POST = proxy; +export const PUT = proxy; +export const PATCH = proxy; +export const DELETE = proxy;