diff --git a/backend/src/agents/base/BaseAgent.ts b/backend/src/agents/base/BaseAgent.ts index 48f9700..66d963a 100644 --- a/backend/src/agents/base/BaseAgent.ts +++ b/backend/src/agents/base/BaseAgent.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { VeniceClient, type AgentType, type VeniceClientLike } from '../../venice/index.js'; +import { VeniceClient, type AgentType } from '../../venice/index.js'; import { HeartbeatClient } from '../heartbeat.js'; export interface BaseAgentConfig { diff --git a/backend/src/api/app.ts b/backend/src/api/app.ts index 1e20e90..ab4d478 100644 --- a/backend/src/api/app.ts +++ b/backend/src/api/app.ts @@ -28,6 +28,12 @@ import { createCorsMiddleware } from "./middleware/cors"; import { requestId } from "./middleware/requestId"; import { requestLogger } from "./middleware/requestLogger"; import { errorHandler } from "./middleware/errorHandler"; +import { validate } from "./middleware/validate"; +import { + CreateTaskSchema, + TaskQuerySchema, + TaskIdParamSchema, +} from "./schemas/task.schema"; import { createLogger } from "../utils/logger"; import { createTaskDb, getTaskDb } from "../db/tasks"; import { createHeartbeatService, type HeartbeatServiceOptions } from "../services/heartbeat"; @@ -108,14 +114,107 @@ export function createApp(opts: AppOptions = {}): { app.post("/api/agents/register", registerRateLimitMiddleware); app.use("/api/agents", agentsRouter); - // ── API docs ───────────────────────────────────────────────────────────────── - app.use("/docs", swaggerUi.serve, swaggerUi.setup(openapiSpec)); - app.get("/openapi.json", (_req: Request, res: Response) => { - res.json(openapiSpec); - }); + // ── POST /api/tasks ──────────────────────────────────────────────────────── + app.post( + "/api/tasks", + authMiddleware, + rateLimitMiddleware, + validate({ body: CreateTaskSchema }), + (req: Request, res: Response) => { + const { prompt, walletPublicKey } = req.body as { + prompt: string; + walletPublicKey?: string; + }; + + const taskId = `task_${randomUUID().replace(/-/g, "").slice(0, 12)}`; + const dag = decompose(taskId, prompt); + const now = new Date().toISOString(); + const correlationId = res.locals.requestId; + + createTask({ + taskId, + prompt, + walletPublicKey: + walletPublicKey ?? + (req.headers["walletpublickey"] as string | undefined) ?? + "anonymous", + status: "queued", + dag, + createdAt: now, + updatedAt: now, + requestId: correlationId, + }); + + const log = createLogger({ requestId: correlationId, taskId }); + + // Run the DAG asynchronously — do not await + setImmediate(() => { + executeDAG(getTask(taskId)!, dispatch, releasePayment).catch((err) => { + log.error({ err }, "DAG execution error"); + }); + }); + + log.info({ dagNodeCount: dag.length }, "task created"); - // ── Task routes ──────────────────────────────────────────────────────────── - app.use("/api/tasks", createTasksRouter(dispatch, releasePayment)); + return res + .status(201) + .json({ taskId, dagPreview: dag, status: "queued" }); + }, + ); + + // ── GET /api/tasks ───────────────────────────────────────────────────────── + app.get( + "/api/tasks", + authMiddleware, + validate({ query: TaskQuerySchema }), + (req: Request, res: Response) => { + const walletPublicKey = req.headers["walletpublickey"] as + string | undefined; + if (!walletPublicKey) + return res.status(401).json({ error: "walletpublickey header required" }); + const { page, pageSize, status, sort, q } = req.query as unknown as { + page: number; + pageSize: number; + status?: string; + sort: "createdAt:asc" | "createdAt:desc"; + q?: string; + }; + const taskDb = createTaskDb(getTaskDb()); + const { tasks, total } = taskDb.list(walletPublicKey, page, pageSize, { + status, + q, + sort, + }); + return res.json({ tasks, total, page, pageSize }); + }, + ); + + // ── GET /api/tasks/:id ───────────────────────────────────────────────────── + app.get( + "/api/tasks/:id", + validate({ params: TaskIdParamSchema }), + (req: Request, res: Response) => { + const task = getTask(req.params.id!); + if (!task) return res.status(404).json({ error: "Task not found" }); + return res.json({ ...task, id: task.taskId, dag: task.dag }); + }, + ); + + // ── DELETE /api/tasks/:id ────────────────────────────────────────────────── + app.delete( + "/api/tasks/:id", + validate({ params: TaskIdParamSchema }), + (req: Request, res: Response) => { + const task = getTask(req.params.id!); + if (!task) return res.status(404).json({ error: "Task not found" }); + if (task.status === "running") { + return res.status(409).json({ error: "Cannot cancel a running task" }); + } + const taskDb = createTaskDb(getTaskDb()); + taskDb.updateStatus(req.params.id!, "cancelled"); + return res.json({ ...task, id: task.taskId, status: "cancelled" }); + }, + ); // ── HTTP server ──────────────────────────────────────────────────────────── const httpServer = createServer(app); diff --git a/backend/src/api/middleware/validate.ts b/backend/src/api/middleware/validate.ts index 8eab7be..b0369b0 100644 --- a/backend/src/api/middleware/validate.ts +++ b/backend/src/api/middleware/validate.ts @@ -1,28 +1,76 @@ -import { z, ZodSchema } from "zod"; -import { Request, Response, NextFunction } from "express"; +import type { Request, Response, NextFunction } from "express"; +import { ZodError, type ZodSchema, type z } from "zod"; + +/** A single field-level validation failure. */ +export interface FieldError { + /** Dotted path to the offending field (e.g. "body.prompt"). */ + path: string; + message: string; +} + +/** Structured 400 body returned for invalid requests. */ +export interface ValidationErrorBody { + error: string; + details: FieldError[]; +} + +/** + * Which parts of the request to validate and against which schema. + * Only the keys provided are validated; omit a key to skip it. + */ +export interface ValidateTargets { + body?: ZodSchema; + query?: ZodSchema; + params?: ZodSchema; +} /** - * Reusable Zod validation middleware. + * Reusable validation middleware. Validates req.body / req.query / req.params + * against the supplied Zod schemas, sanitizing input on the way (trimmed + * strings, coerced numbers). On failure responds with a structured 400: * - * Parses `req.body` against the provided schema. On success the parsed - * (and potentially transformed) data replaces `req.body` so downstream - * handlers receive the sanitised value. On failure the middleware short- - * circuits with a 400 response containing structured field errors. + * { error: string, details: FieldError[] } * - * @example - * router.post("/", validate(mySchema), handler); + * Internally, successful parses are written back onto the request so later + * handlers see the sanitized, coerced values (and so coerced numbers stay + * numbers rather than strings). */ -export function validate(schema: ZodSchema) { +export function validate(targets: ValidateTargets) { return (req: Request, res: Response, next: NextFunction): void => { - const result = schema.safeParse(req.body); - if (!result.success) { - res.status(400).json({ + const details: FieldError[] = []; + + for (const part of ["body", "query", "params"] as const) { + const schema = targets[part]; + if (!schema) continue; + + const result = schema.safeParse(req[part]); + if (!result.success) { + collectErrors(result.error, part, details); + continue; + } + // Write the sanitized/coerced value back so handlers use the parsed form. + (req as unknown as Record)[part] = result.data; + } + + if (details.length > 0) { + const body: ValidationErrorBody = { error: "Validation failed", - details: result.error.flatten().fieldErrors, - }); + details, + }; + res.status(400).json(body); return; } - req.body = result.data; + next(); }; } + +function collectErrors(error: ZodError, part: string, out: FieldError[]): void { + for (const issue of error.issues) { + const fieldPath = issue.path.length > 0 ? issue.path.join(".") : part; + out.push({ path: `${part}.${fieldPath}`, message: issue.message }); + } +} + +/** Small convenience helper for handlers that still want the inferred type. */ +export type InferSchema = z.infer; diff --git a/backend/src/api/routes/agents.ts b/backend/src/api/routes/agents.ts index ba7e3db..d516cf2 100644 --- a/backend/src/api/routes/agents.ts +++ b/backend/src/api/routes/agents.ts @@ -1,26 +1,18 @@ import { Router, Request, Response } from "express"; -import { z } from "zod"; -import { Horizon, Keypair } from "@stellar/stellar-sdk"; +import { Keypair, Server as HorizonServer } from "@stellar/stellar-sdk"; import { getAgentDb, createAgentDb, AgentDb } from "../../db/agents"; -import { heartbeatRateLimitMiddleware } from "../middleware/rateLimit"; +import { validate } from "../middleware/validate"; +import { + RegisterAgentSchema, + AgentListQuerySchema, + AgentIdParamSchema, +} from "../schemas/agent.schema"; export interface AgentsRouterOptions { healthTimeoutMs?: number; db?: AgentDb; } -const STELLAR_PUBLIC_KEY_REGEX = /^G[A-Z2-7]{55}$/; - -const RegisterAgentSchema = z.object({ - agentId: z.string(), - capabilities: z.array(z.string()), - pricingXLM: z.number().positive("Price must be positive"), - endpoint: z.string().url(), - stellarPublicKey: z - .string() - .regex(STELLAR_PUBLIC_KEY_REGEX, "Invalid Stellar public key format"), -}); - const DEFAULT_HEALTH_TIMEOUT_MS = 3_000; const HORIZON_URL = process.env.STELLAR_HORIZON_URL || "https://horizon-testnet.stellar.org"; const horizon = new Horizon.Server(HORIZON_URL); @@ -67,19 +59,31 @@ export function createAgentsRouter(options: AgentsRouterOptions = {}): Router { * $ref: '#/components/schemas/Error' */ // GET /api/agents - router.get("/", (req: Request, res: Response): void => { - const db = getDb(); - const capability = req.query.capability as string | undefined; - const minReputation = req.query.minReputation ? parseFloat(req.query.minReputation as string) : undefined; - const maxPriceXLM = req.query.maxPriceXLM ? parseFloat(req.query.maxPriceXLM as string) : undefined; - - try { - const agents = db.list({ capability, minReputation, maxPriceXLM }); - res.json(agents); - } catch (err) { - res.status(500).json({ error: "Internal Server Error" }); - } - }); + router.get( + "/", + validate({ query: AgentListQuerySchema }), + (req: Request, res: Response): void => { + const db = getDb(); + const { capability, minReputation, maxPriceXLM, status } = req.query as { + capability?: string; + minReputation?: number; + maxPriceXLM?: number; + status?: "online" | "offline"; + }; + + try { + const agents = db.list({ + capability, + minReputation, + maxPriceXLM, + status, + }); + res.json(agents); + } catch (err) { + res.status(500).json({ error: "Internal Server Error" }); + } + }, + ); /** * @openapi @@ -109,15 +113,19 @@ export function createAgentsRouter(options: AgentsRouterOptions = {}): Router { * $ref: '#/components/schemas/Error' */ // GET /api/agents/:id - router.get("/:id", (req: Request, res: Response): void => { - const db = getDb(); - const agent = db.findById(req.params.id); - if (!agent) { - res.status(404).json({ error: "Agent not found" }); - return; - } - res.json(agent); - }); + router.get( + "/:id", + validate({ params: AgentIdParamSchema }), + (req: Request, res: Response): void => { + const db = getDb(); + const agent = db.findById(req.params.id); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return; + } + res.json(agent); + }, + ); /** * @openapi @@ -158,37 +166,41 @@ export function createAgentsRouter(options: AgentsRouterOptions = {}): Router { * $ref: '#/components/schemas/Error' */ // GET /api/agents/:id/health - router.get("/:id/health", async (req: Request, res: Response): Promise => { - const db = getDb(); - const agent = db.findById(req.params.id); - if (!agent) { - res.status(404).json({ error: "Agent not found" }); - return; - } + router.get( + "/:id/health", + validate({ params: AgentIdParamSchema }), + async (req: Request, res: Response): Promise => { + const db = getDb(); + const agent = db.findById(req.params.id); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return; + } - const startedAt = Date.now(); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), healthTimeoutMs); + const startedAt = Date.now(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), healthTimeoutMs); - try { - const response = await fetch(agent.endpoint, { - method: "GET", - signal: controller.signal, - }); + try { + const response = await fetch(agent.endpoint, { + method: "GET", + signal: controller.signal, + }); - res.status(200).json({ - status: response.ok ? "healthy" : "unreachable", - latencyMs: Date.now() - startedAt, - }); - } catch { - res.status(200).json({ - status: "unreachable", - latencyMs: Date.now() - startedAt, - }); - } finally { - clearTimeout(timeout); - } - }); + res.status(200).json({ + status: response.ok ? "healthy" : "unreachable", + latencyMs: Date.now() - startedAt, + }); + } catch { + res.status(200).json({ + status: "unreachable", + latencyMs: Date.now() - startedAt, + }); + } finally { + clearTimeout(timeout); + } + }, + ); /** * @openapi @@ -292,17 +304,19 @@ export function createAgentsRouter(options: AgentsRouterOptions = {}): Router { * $ref: '#/components/schemas/Error' */ // POST /api/agents/register - router.post("/register", async (req: Request, res: Response): Promise => { - const parse = RegisterAgentSchema.safeParse(req.body); - if (!parse.success) { - res.status(400).json({ error: parse.error.flatten() }); - return; - } - - const data = parse.data; - - // Verify Stellar account exists - if (process.env.SKIP_STELLAR_ACCOUNT_VERIFY !== "true") { + router.post( + "/register", + validate({ body: RegisterAgentSchema }), + async (req: Request, res: Response): Promise => { + const data = req.body as { + agentId: string; + capabilities: string[]; + pricingXLM: number; + endpoint: string; + stellarPublicKey: string; + }; + + // Verify Stellar account exists try { await horizon.loadAccount(data.stellarPublicKey); } catch (err: any) { @@ -310,29 +324,44 @@ export function createAgentsRouter(options: AgentsRouterOptions = {}): Router { res.status(400).json({ error: "StellarAccountNotFound" }); return; } - if (process.env.NODE_ENV !== "test") { - res.status(400).json({ error: "Failed to verify Stellar account", details: err.message }); - return; - } + res.status(400).json({ error: "Failed to verify Stellar account" }); + return; } - } - - const db = getDb(); - const agent = { - id: data.agentId, - capabilities: data.capabilities, - pricingXLM: data.pricingXLM, - endpoint: data.endpoint, - stellarPublicKey: data.stellarPublicKey, - reputationScore: 0, - lastSeenAt: new Date().toISOString(), - status: 'online' as const - }; - - db.upsert(agent); - - res.status(201).json(agent); - }); + + const db = getDb(); + const agent = { + id: data.agentId, + capabilities: data.capabilities, + pricingXLM: data.pricingXLM, + endpoint: data.endpoint, + stellarPublicKey: data.stellarPublicKey, + reputationScore: 0, + lastSeenAt: new Date().toISOString(), + status: "online" as const, + }; + + db.upsert(agent); + + res.status(201).json(agent); + }, + ); + + // POST /api/agents/:id/heartbeat + router.post( + "/:id/heartbeat", + validate({ params: AgentIdParamSchema }), + (req: Request, res: Response): void => { + const db = getDb(); + const agent = db.findById(req.params.id); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); + return; + } + + db.upsert({ ...agent, lastSeenAt: new Date().toISOString(), status: "online" }); + res.status(204).send(); + }, + ); // POST /api/agents/:id/heartbeat router.post("/:id/heartbeat", (req: Request, res: Response): void => { @@ -348,37 +377,44 @@ export function createAgentsRouter(options: AgentsRouterOptions = {}): Router { }); // DELETE /api/agents/:id - router.delete("/:id", (req: Request, res: Response): void => { - const db = getDb(); - const agent = db.findById(req.params.id); - if (!agent) { - res.status(404).json({ error: "Agent not found" }); - return; - } - - const signature = req.headers["x-signature"] as string; - const challenge = req.headers["x-challenge"] as string; - - if (!signature || !challenge) { - res.status(401).json({ error: "Missing challenge or signature" }); - return; - } - - try { - const keypair = Keypair.fromPublicKey(agent.stellarPublicKey); - const isValid = keypair.verify(Buffer.from(challenge), Buffer.from(signature, "base64")); - if (!isValid) { - res.status(401).json({ error: "Invalid signature" }); + router.delete( + "/:id", + validate({ params: AgentIdParamSchema }), + (req: Request, res: Response): void => { + const db = getDb(); + const agent = db.findById(req.params.id); + if (!agent) { + res.status(404).json({ error: "Agent not found" }); return; } - } catch (err) { - res.status(401).json({ error: "Invalid signature format" }); - return; - } - - db.delete(req.params.id); - res.json({ message: "Agent deleted successfully" }); - }); + + const signature = req.headers["x-signature"] as string; + const challenge = req.headers["x-challenge"] as string; + + if (!signature || !challenge) { + res.status(401).json({ error: "Missing challenge or signature" }); + return; + } + + try { + const keypair = Keypair.fromPublicKey(agent.stellarPublicKey); + const isValid = keypair.verify( + Buffer.from(challenge), + Buffer.from(signature, "base64"), + ); + if (!isValid) { + res.status(401).json({ error: "Invalid signature" }); + return; + } + } catch (err) { + res.status(401).json({ error: "Invalid signature format" }); + return; + } + + db.delete(req.params.id); + res.json({ message: "Agent deleted successfully" }); + }, + ); return router; } diff --git a/backend/src/api/routes/tasks.ts b/backend/src/api/routes/tasks.ts index f852435..c15237c 100644 --- a/backend/src/api/routes/tasks.ts +++ b/backend/src/api/routes/tasks.ts @@ -1,14 +1,14 @@ import { Router, Request, Response } from "express"; -import { z } from "zod"; import { nanoid } from "nanoid"; import { getTaskDb, createTaskDb } from "../../db/tasks"; import { decompose } from "../../coordinator"; import type { Task } from "../../types/task"; -import { executeDAG, type DispatchFn, type PaymentReleaseFn } from "../../coordinator/coordinator"; -import { createTask, getTask } from "../../coordinator/taskStore"; -import { createLogger } from "../../utils/logger"; import { validate } from "../middleware/validate"; -import { rateLimitMiddleware } from "../middleware/rateLimit"; +import { + CreateTaskSchema, + TaskQuerySchema, + TaskIdParamSchema, +} from "../schemas/task.schema"; // ── Validation config ──────────────────────────────────────────────────────── // Read at module load time so the value is stable for the lifetime of the @@ -17,220 +17,102 @@ import { rateLimitMiddleware } from "../middleware/rateLimit"; const MAX_PROMPT_LENGTH = Number(process.env.MAX_PROMPT_LENGTH ?? 10_000); const DAILY_TASK_LIMIT = Number(process.env.DAILY_TASK_LIMIT_PER_WALLET ?? 100); -// ── Schemas ────────────────────────────────────────────────────────────────── - -/** - * Schema for POST /api/tasks request body. - * - * Security measures: - * - `prompt` enforces a configurable max length to prevent token-cost abuse - * (issue #181: 100 000-character prompts → massive Venice AI bills). - * - `.transform()` strips C0 control characters (excl. HT, LF, CR) to - * mitigate prompt injection via invisible control sequences. - */ -// `maxBudgetXLM` and `walletPublicKey` are optional, matching the handler this -// router replaced (previously inline in app.ts). The old contract only rejected -// maxBudgetXLM when it was present and below the minimum, and accepted -// walletPublicKey from the body; requiring them here silently broke every -// caller that omitted them, including the e2e suite. -export const createTaskSchema = z.object({ - prompt: z - .string() - .min(1, "Prompt is required") - .max(MAX_PROMPT_LENGTH, `Prompt too long (max ${MAX_PROMPT_LENGTH} characters)`) - // Strip C0 control characters (except tab \x09, newline \x0A, carriage return \x0D) - // to prevent prompt injection via embedded invisible control sequences. - .transform((s) => s.replace(/[\x00-\x08\x0E-\x1F]/g, "").trim()), - walletPublicKey: z.string().optional(), - maxBudgetXLM: z.number().min(0.1).optional().default(1), - agentPreferences: z.array(z.string()).optional(), -}); - -const TaskListSchema = z.object({ - page: z.coerce.number().int().min(1).default(1), - pageSize: z.coerce.number().int().min(1).max(100).default(10), - status: z.enum(["queued", "running", "completed", "failed", "cancelled"]).optional(), - sort: z.enum(["createdAt:desc", "createdAt:asc"]).default("createdAt:desc"), - q: z.string().optional(), -}); - -// ── Router factory ─────────────────────────────────────────────────────────── - -export function createTasksRouter(dispatch: DispatchFn, releasePayment: PaymentReleaseFn): Router { - const tasksRouter = Router(); - - /** - * @openapi - * /api/tasks: - * post: - * summary: Create a new task - * tags: [Tasks] - * security: - * - WalletAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [prompt, maxBudgetXLM] - * properties: - * prompt: - * type: string - * minLength: 1 - * maxLength: 10000 - * maxBudgetXLM: - * type: number - * minimum: 0.1 - * agentPreferences: - * type: array - * items: - * type: string - * responses: - * 201: - * description: Task created and queued - * content: - * application/json: - * schema: - * type: object - * properties: - * taskId: - * type: string - * example: task_ab12cd34ef56 - * dagPreview: - * type: object - * status: - * type: string - * enum: [queued] - * 400: - * description: Validation error - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - * 429: - * description: Rate limit or daily quota exceeded - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ - // POST /api/tasks — rate-limited, then Zod-validated - tasksRouter.post("/", rateLimitMiddleware, validate(createTaskSchema), (req: Request, res: Response): void => { - const { prompt } = req.body as z.infer; - // Body first, then the header, then "anonymous" — the precedence the - // previous app.ts handler used. - const walletPublicKey: string = - (req.body as z.infer).walletPublicKey ?? - (req.headers["walletpublickey"] as string | undefined) ?? - "anonymous"; +// POST /api/tasks +tasksRouter.post( + "/", + validate({ body: CreateTaskSchema }), + (req: Request, res: Response): void => { + const { prompt, walletPublicKey } = req.body as { + prompt: string; + walletPublicKey?: string; + }; - // ── Per-wallet daily quota ─────────────────────────────────────────────── - // Reject early if the wallet has already hit its 24-hour task ceiling. - // This prevents a single wallet from exhausting the Venice AI token budget. - if (DAILY_TASK_LIMIT > 0 && walletPublicKey !== "anonymous") { - const db = createTaskDb(getTaskDb()); - const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); - const { total } = db.list(walletPublicKey, 1, 1, { createdAfter: since }); - if (total >= DAILY_TASK_LIMIT) { - res.status(429).json({ - error: { - message: `Daily task limit reached (max ${DAILY_TASK_LIMIT} per 24 hours)`, - code: "DAILY_LIMIT_EXCEEDED", - }, - }); - return; - } - } + const wallet = walletPublicKey ?? (req.headers["walletpublickey"] as string) ?? ""; - const taskId = `task_${nanoid(12)}`; - const dag = decompose(taskId, prompt); + const dag = decompose(prompt); const now = new Date().toISOString(); const task: Task = { - id: taskId, + id: `task_${nanoid(12)}`, prompt, - walletPublicKey, + walletPublicKey: wallet, status: "queued", - dag, + dagJson: JSON.stringify(dag), createdAt: now, updatedAt: now, }; - createTask(task); - - const log = createLogger({ taskId }); - - // Run the DAG asynchronously — do not await - setImmediate(() => { - executeDAG(getTask(taskId)!, dispatch, releasePayment).catch((err) => { - log.error({ err }, "DAG execution error"); - }); - }); + const db = createTaskDb(getTaskDb()); + db.insert(task); res.status(201).json({ taskId: task.id, dagPreview: dag, status: "queued" }); - }); + }, +); - /** - * @openapi - * /api/tasks: - * get: - * summary: List tasks - * tags: [Tasks] - * security: - * - WalletAuth: [] - * parameters: - * - in: query - * name: page - * schema: { type: integer, minimum: 1, default: 1 } - * - in: query - * name: pageSize - * schema: { type: integer, minimum: 1, maximum: 100, default: 10 } - * - in: query - * name: status - * schema: - * type: string - * enum: [queued, running, completed, failed, cancelled] - * - in: query - * name: sort - * schema: - * type: string - * enum: [createdAt:desc, createdAt:asc] - * default: createdAt:desc - * - in: query - * name: q - * schema: { type: string } - * responses: - * 200: - * description: Paginated task list - * content: - * application/json: - * schema: - * type: object - * properties: - * tasks: - * type: array - * items: - * $ref: '#/components/schemas/Task' - * total: { type: integer } - * page: { type: integer } - * pageSize: { type: integer } - * 400: - * description: Invalid query parameters - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ - // GET /api/tasks - tasksRouter.get("/", (req: Request, res: Response): void => { +/** + * @openapi + * /api/tasks: + * get: + * summary: List tasks + * tags: [Tasks] + * security: + * - WalletAuth: [] + * parameters: + * - in: query + * name: page + * schema: { type: integer, minimum: 1, default: 1 } + * - in: query + * name: pageSize + * schema: { type: integer, minimum: 1, maximum: 100, default: 10 } + * - in: query + * name: status + * schema: + * type: string + * enum: [queued, running, completed, failed, cancelled] + * - in: query + * name: sort + * schema: + * type: string + * enum: [createdAt:desc, createdAt:asc] + * default: createdAt:desc + * - in: query + * name: q + * schema: { type: string } + * responses: + * 200: + * description: Paginated task list + * content: + * application/json: + * schema: + * type: object + * properties: + * tasks: + * type: array + * items: + * $ref: '#/components/schemas/Task' + * total: { type: integer } + * page: { type: integer } + * pageSize: { type: integer } + * 400: + * description: Invalid query parameters + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ +// GET /api/tasks +tasksRouter.get( + "/", + validate({ query: TaskQuerySchema }), + (req: Request, res: Response): void => { const walletPublicKey = (req.headers["walletpublickey"] as string) ?? ""; - const parse = TaskListSchema.safeParse(req.query); - if (!parse.success) { - res.status(400).json({ error: parse.error.flatten() }); - return; - } + const { page, pageSize, status, sort, q } = req.query as unknown as { + page: number; + pageSize: number; + status?: Task["status"]; + sort: "createdAt:desc" | "createdAt:asc"; + q?: string; + }; - const { page, pageSize, status, sort, q } = parse.data; const db = createTaskDb(getTaskDb()); const { tasks, total } = db.list(walletPublicKey, page, pageSize, { status, @@ -238,124 +120,110 @@ export function createTasksRouter(dispatch: DispatchFn, releasePayment: PaymentR q: q && q.length > 0 ? q : undefined, }); - res.json({ tasks, total, page, pageSize }); - }); + res.json({ + tasks: tasks.map((t) => ({ ...t, dag: JSON.parse(t.dagJson) })), + total, + page, + pageSize, + }); + }, +); - /** - * @openapi - * /api/tasks/{id}: - * get: - * summary: Get a task by ID - * tags: [Tasks] - * security: - * - WalletAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: { type: string } - * responses: - * 200: - * description: Task found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Task' - * 403: - * description: Access denied — walletpublickey header is missing or does not match the task owner - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - * 404: - * description: Task not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ - // GET /api/tasks/:id - tasksRouter.get("/:id", (req: Request, res: Response): void => { +/** + * @openapi + * /api/tasks/{id}: + * get: + * summary: Get a task by ID + * tags: [Tasks] + * security: + * - WalletAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string } + * responses: + * 200: + * description: Task found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Task' + * 404: + * description: Task not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ +// GET /api/tasks/:id +tasksRouter.get( + "/:id", + validate({ params: TaskIdParamSchema }), + (req: Request, res: Response): void => { const db = createTaskDb(getTaskDb()); const task = db.findById(req.params.id); if (!task) { res.status(404).json({ error: "Task not found" }); return; } - const requesterKey = req.headers["walletpublickey"] as string; - if (!requesterKey || requesterKey !== task.walletPublicKey) { - res.status(403).json({ error: "Access denied" }); - return; - } - res.json(task); - }); + res.json({ ...task, dag: JSON.parse(task.dagJson) }); + }, +); - /** - * @openapi - * /api/tasks/{id}: - * delete: - * summary: Cancel a task - * description: Cancels a queued task. Returns 409 if the task is currently running. - * tags: [Tasks] - * security: - * - WalletAuth: [] - * parameters: - * - in: path - * name: id - * required: true - * schema: { type: string } - * responses: - * 200: - * description: Task cancelled - * content: - * application/json: - * schema: - * type: object - * properties: - * taskId: { type: string } - * status: { type: string, enum: [cancelled] } - * 403: - * description: Not authorized to cancel this task - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - * 404: - * description: Task not found - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - * 409: - * description: Cannot cancel task in current status - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ - // DELETE /api/tasks/:id - tasksRouter.delete("/:id", (req: Request, res: Response): void => { +/** + * @openapi + * /api/tasks/{id}: + * delete: + * summary: Cancel a task + * description: Cancels a queued task. Returns 409 if the task is currently running. + * tags: [Tasks] + * security: + * - WalletAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: { type: string } + * responses: + * 200: + * description: Task cancelled + * content: + * application/json: + * schema: + * type: object + * properties: + * taskId: { type: string } + * status: { type: string, enum: [cancelled] } + * 404: + * description: Task not found + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 409: + * description: Cannot cancel a running task + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ +// DELETE /api/tasks/:id +tasksRouter.delete( + "/:id", + validate({ params: TaskIdParamSchema }), + (req: Request, res: Response): void => { const db = createTaskDb(getTaskDb()); const task = db.findById(req.params.id); if (!task) { res.status(404).json({ error: "Task not found" }); return; } - - const requesterKey = req.headers["walletpublickey"] as string; - if (!requesterKey || requesterKey !== task.walletPublicKey) { - res.status(403).json({ error: "Not authorized to cancel this task" }); + if (task.status === "running") { + res.status(409).json({ error: "Cannot cancel a running task" }); return; } - - if (task.status !== "queued") { - res.status(409).json({ error: `Cannot cancel task in '${task.status}' status` }); - return; - } - db.updateStatus(req.params.id, "cancelled"); res.json({ taskId: req.params.id, status: "cancelled" }); - }); - - return tasksRouter; -} + }, +); diff --git a/backend/src/api/schemas/agent.schema.ts b/backend/src/api/schemas/agent.schema.ts new file mode 100644 index 0000000..1d95cf4 --- /dev/null +++ b/backend/src/api/schemas/agent.schema.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; +import { IdParamSchema, trimmedString } from "./common.schema"; + +/** + * Agent API schemas. Request types are derived from these via `z.infer`. + */ + +/** POST /api/agents/register */ +export const RegisterAgentSchema = z.object({ + agentId: trimmedString(128, "agentId"), + capabilities: z.array(z.string().min(1)).min(1, "at least one capability is required"), + pricingXLM: z.number().min(0, "pricingXLM must be >= 0"), + endpoint: z.string().url("endpoint must be a valid URL"), + stellarPublicKey: trimmedString(256, "stellarPublicKey"), +}); + +/** GET /api/agents list query */ +export const AgentListQuerySchema = z.object({ + capability: z.string().optional(), + minReputation: z.coerce.number().min(0).optional(), + maxPriceXLM: z.coerce.number().min(0).optional(), + status: z.enum(["online", "offline"]).optional(), +}); + +export const AgentIdParamSchema = IdParamSchema; + +/** POST /api/agents/:id/heartbeat / DELETE /api/agents/:id — id only */ +export type RegisterAgentInput = z.infer; +export type AgentListQueryInput = z.infer; +export type AgentIdParam = z.infer; diff --git a/backend/src/api/schemas/common.schema.ts b/backend/src/api/schemas/common.schema.ts new file mode 100644 index 0000000..4380fe7 --- /dev/null +++ b/backend/src/api/schemas/common.schema.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; + +/** + * Shared schema fragments used across multiple endpoints. + * Types are derived from these via `z.infer` — never duplicated by hand. + */ + +export const TaskStatusSchema = z.enum([ + "queued", + "running", + "completed", + "failed", + "cancelled", +]); + +export const SortOrderSchema = z.enum(["createdAt:desc", "createdAt:asc"]); + +/** A required path/route parameter id (e.g. /tasks/:id). */ +export const IdParamSchema = z.object({ + id: z.string().min(1, "id is required"), +}); + +/** + * Standard pagination query params. Numbers are coerced from the string + * values Express parses out of the query string and clamped to safe bounds. + */ +export const PaginationQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(10), +}); + +/** Trims a string and rejects pure-whitespace which would collapse to empty. */ +export const trimmedString = (max: number, label: string) => + z + .string() + .transform((s) => s.trim()) + .pipe( + z + .string() + .min(1, `${label} is required`) + .max(max, `${label} must be at most ${max} characters`), + ); + +export type TaskStatus = z.infer; +export type PaginationQuery = z.infer; +export type IdParam = z.infer; diff --git a/backend/src/api/schemas/task.schema.ts b/backend/src/api/schemas/task.schema.ts new file mode 100644 index 0000000..826ae38 --- /dev/null +++ b/backend/src/api/schemas/task.schema.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; +import { PaginationQuerySchema, SortOrderSchema, TaskStatusSchema, trimmedString } from "./common.schema"; + +/** + * Task API schemas. Request types are derived from these via `z.infer`. + */ + +export const CreateTaskSchema = z.object({ + prompt: trimmedString(10000, "prompt"), + maxBudgetXLM: z.number().min(0.1, "maxBudgetXLM must be >= 0.1"), + agentPreferences: z.array(z.string()).optional(), + walletPublicKey: z.string().optional(), +}); + +export const TaskQuerySchema = PaginationQuerySchema.extend({ + status: TaskStatusSchema.optional(), + sort: SortOrderSchema.default("createdAt:desc"), + q: z.string().optional(), +}); + +export const TaskIdParamSchema = z.object({ + id: z.string().min(1, "id is required"), +}); + +export type CreateTaskInput = z.infer; +export type TaskQueryInput = z.infer; +export type TaskIdParam = z.infer; diff --git a/backend/src/db/agents.ts b/backend/src/db/agents.ts index bd50630..920ec5b 100644 --- a/backend/src/db/agents.ts +++ b/backend/src/db/agents.ts @@ -50,10 +50,7 @@ export interface AgentDb { list(filters?: { capability?: string; minReputation?: number; maxPriceXLM?: number; status?: string }): AgentRecord[]; delete(id: string): void; updateReputation(id: string, delta: number): void; - markAllOffline(): void; - updateLastSeen(agentId: string): void; - markStaleAgents(staleThresholdMinutes?: number): number; - deleteOfflineAgents(offlineThresholdHours?: number): number; + markOffline(olderThan: string): void; } export function createAgentDb(db: Database.Database): AgentDb { @@ -123,36 +120,8 @@ export function createAgentDb(db: Database.Database): AgentDb { db.prepare("UPDATE agents SET reputationScore = reputationScore + ? WHERE id = ?").run(delta, id); }, - markAllOffline(): void { - db.prepare("UPDATE agents SET status = 'offline' WHERE status = 'online'").run(); - }, - - updateLastSeen(agentId: string): void { - db.prepare(` - UPDATE agents - SET lastSeenAt = datetime('now'), - status = 'online' - WHERE id = ? - `).run(agentId); - }, - - markStaleAgents(staleThresholdMinutes: number = 5): number { - const result = db.prepare(` - UPDATE agents - SET status = 'offline' - WHERE status = 'online' - AND datetime(lastSeenAt, '+' || ? || ' minutes') < datetime('now') - `).run(staleThresholdMinutes); - return result.changes; - }, - - deleteOfflineAgents(offlineThresholdHours: number = 24): number { - const result = db.prepare(` - DELETE FROM agents - WHERE status = 'offline' - AND datetime(lastSeenAt, '+' || ? || ' hours') < datetime('now') - `).run(offlineThresholdHours); - return result.changes; + markOffline(olderThan: string): void { + db.prepare("UPDATE agents SET status = 'offline' WHERE lastSeenAt < ? AND status = 'online'").run(olderThan); } }; } diff --git a/backend/src/index.ts b/backend/src/index.ts index b72e8d6..216fa76 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -9,9 +9,6 @@ import { initializeAgents, globalAgentRegistry } from "./agents"; import { startAgentSync, stopAgentSync } from "./registry/sync"; import { loadConfig, getConfig } from "./config"; import { AgentCleanupService } from "./services/agentCleanup"; -import { createTaskDb, getTaskDb, closeTaskDb } from "./db/tasks"; -import { createAgentDb, getAgentDb, closeAgentDb } from "./db/agents"; -import { closeDb } from "./db/index"; async function main() { // ── Validate env config at startup ────────────────────────────────────────── diff --git a/backend/src/services/agentCleanup.ts b/backend/src/services/agentCleanup.ts index 49d12c3..d5cf697 100644 --- a/backend/src/services/agentCleanup.ts +++ b/backend/src/services/agentCleanup.ts @@ -39,10 +39,10 @@ export class AgentCleanupService { if (this.stopped) return; try { - const staleMinutes = Math.ceil(this.ttlMs / 60_000); + const cutoff = new Date(Date.now() - this.ttlMs).toISOString(); const db = createAgentDb(getAgentDb()); - const count = db.markStaleAgents(staleMinutes); - this.log.info({ count, staleMinutes }, 'marked stale agents offline'); + db.markOffline(cutoff); + this.log.info({ cutoff }, 'marked stale agents offline'); } catch (err) { this.log.error({ err }, 'cleanup tick failed'); } diff --git a/backend/tests/agents.test.ts b/backend/tests/agents.test.ts index f3fdcb6..3c40aa2 100644 --- a/backend/tests/agents.test.ts +++ b/backend/tests/agents.test.ts @@ -146,108 +146,3 @@ describe("Agents API route", () => { expect(response.body).toEqual({ error: "Agent not found" }); }); }); - -describe("Stellar public key validation", () => { - const VALID_KEY = "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGDG6NXGPTVMLHK4HZ7HHN"; - - beforeAll(() => { - process.env.SKIP_STELLAR_ACCOUNT_VERIFY = "true"; - }); - - afterAll(() => { - delete process.env.SKIP_STELLAR_ACCOUNT_VERIFY; - }); - - describe("Agent registration", () => { - it("returns 400 for key missing the G prefix", async () => { - const response = await request(createTestApp()).post("/api/agents/register").send({ - agentId: "test-agent", - capabilities: ["coding"], - pricingXLM: 1, - endpoint: "http://localhost:3001/health", - stellarPublicKey: "AAXXWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGDG6NXGPTVMLHK4HZ7HHN", - }); - - expect(response.status).toBe(400); - }); - - it("returns 400 for key shorter than 56 characters", async () => { - const response = await request(createTestApp()).post("/api/agents/register").send({ - agentId: "test-agent", - capabilities: ["coding"], - pricingXLM: 1, - endpoint: "http://localhost:3001/health", - stellarPublicKey: "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCG", - }); - - expect(response.status).toBe(400); - }); - - it("returns 400 for negative pricingXLM", async () => { - const response = await request(createTestApp()).post("/api/agents/register").send({ - agentId: "test-agent", - capabilities: ["coding"], - pricingXLM: -1, - endpoint: "http://localhost:3001/health", - stellarPublicKey: VALID_KEY, - }); - - expect(response.status).toBe(400); - }); - - it("returns 400 for zero pricingXLM", async () => { - const response = await request(createTestApp()).post("/api/agents/register").send({ - agentId: "test-agent", - capabilities: ["coding"], - pricingXLM: 0, - endpoint: "http://localhost:3001/health", - stellarPublicKey: VALID_KEY, - }); - - expect(response.status).toBe(400); - }); - - it("returns 201 for valid Stellar public key", async () => { - const response = await request(createTestApp()).post("/api/agents/register").send({ - agentId: "test-agent", - capabilities: ["coding"], - pricingXLM: 1, - endpoint: "http://localhost:3001/health", - stellarPublicKey: VALID_KEY, - }); - - expect(response.status).toBe(201); - expect(response.body.stellarPublicKey).toBe(VALID_KEY); - }); - }); - - describe("Task creation", () => { - function createTaskTestApp() { - const app = express(); - app.use(express.json()); - const mockDispatch = jest.fn().mockResolvedValue({}); - const mockReleasePayment = jest.fn().mockResolvedValue(undefined); - app.use("/api/tasks", createTasksRouter(mockDispatch, mockReleasePayment)); - return app; - } - - it("returns 400 for invalid walletpublickey header", async () => { - const response = await request(createTaskTestApp()) - .post("/api/tasks") - .set("walletpublickey", "INVALID-KEY-123") - .send({ prompt: "Do something", maxBudgetXLM: 1 }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe("Invalid Stellar public key format"); - }); - - it("returns 400 when walletpublickey header is missing", async () => { - const response = await request(createTaskTestApp()) - .post("/api/tasks") - .send({ prompt: "Do something", maxBudgetXLM: 1 }); - - expect(response.status).toBe(400); - expect(response.body.error).toBe("Invalid Stellar public key format"); - }); - }); -}); diff --git a/backend/tests/validation.test.ts b/backend/tests/validation.test.ts new file mode 100644 index 0000000..39dedb8 --- /dev/null +++ b/backend/tests/validation.test.ts @@ -0,0 +1,84 @@ +import express from "express"; +import request from "supertest"; +import { validate } from "../src/api/middleware/validate"; +import { + CreateTaskSchema, + TaskQuerySchema, +} from "../src/api/schemas/task.schema"; +import { + RegisterAgentSchema, + AgentListQuerySchema, +} from "../src/api/schemas/agent.schema"; + +function appFor(schema: Parameters[0], echo?: string) { + const app = express(); + app.use(express.json()); + const handler = (req: any, res: any) => { + res.json({ body: req.body, query: req.query, params: req.params, echo }); + }; + app.post("/", validate({ body: schema.body }), handler); + app.get("/", validate({ query: schema.query }), handler); + app.get("/:id", validate({ params: schema.params }), handler); + return app; +} + +describe("validate() middleware", () => { + it("returns 400 with structured FieldError[] for invalid body", async () => { + const app = appFor({ body: CreateTaskSchema }); + const res = await request(app).post("/").send({ maxBudgetXLM: 0.05 }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe("Validation failed"); + expect(Array.isArray(res.body.details)).toBe(true); + expect(res.body.details[0]).toHaveProperty("path"); + expect(res.body.details[0]).toHaveProperty("message"); + expect(res.body.details.some((d: any) => d.path === "body.prompt")).toBe(true); + }); + + it("trims strings and coerces numbers on the way through", async () => { + const app = appFor({ body: CreateTaskSchema }); + const res = await request(app) + .post("/") + .send({ prompt: " research AI ", maxBudgetXLM: 1 }); + + expect(res.status).toBe(200); + expect(res.body.body.prompt).toBe("research AI"); + }); + + it("accepts valid query params with coercion (page is a number)", async () => { + const app = appFor({ query: TaskQuerySchema }); + const res = await request(app).get("/?page=2&pageSize=5&status=queued&sort=createdAt:asc"); + + expect(res.status).toBe(200); + expect(typeof res.body.query.page).toBe("number"); + expect(res.body.query.page).toBe(2); + expect(res.body.query.status).toBe("queued"); + }); + + it("rejects an invalid enum value in query with a field error", async () => { + const app = appFor({ query: TaskQuerySchema }); + const res = await request(app).get("/?status=bogus"); + + expect(res.status).toBe(400); + expect(res.body.details.some((d: any) => d.path === "query.status")).toBe(true); + }); + + it("returns 400 for an invalid agent registration body", async () => { + const app = appFor({ body: RegisterAgentSchema }); + const res = await request(app) + .post("/") + .send({ agentId: "", capabilities: [], pricingXLM: -1, endpoint: "not-a-url", stellarPublicKey: "x" }); + + expect(res.status).toBe(400); + expect(Array.isArray(res.body.details)).toBe(true); + }); + + it("coerces numeric query params for agent list", async () => { + const app = appFor({ query: AgentListQuerySchema }); + const res = await request(app).get("/?minReputation=2&maxPriceXLM=3.5&status=online"); + + expect(res.status).toBe(200); + expect(res.body.query.minReputation).toBe(2); + expect(res.body.query.maxPriceXLM).toBe(3.5); + }); +});