diff --git a/.changeset/v0_2_0-auth.md b/.changeset/v0_2_0-auth.md new file mode 100644 index 0000000..d0d47a6 --- /dev/null +++ b/.changeset/v0_2_0-auth.md @@ -0,0 +1,5 @@ +--- +"@reaatech/a2a-reference-auth": minor +--- + +Add `OAuth2Strategy`: token validation plus client-credentials, authorization-code (with PKCE), and refresh grants. Cache the JWKS for the strategy lifetime, accept the `Bearer` scheme case-insensitively (RFC 6749), and add a shared scope-extraction helper. diff --git a/.changeset/v0_2_0-cli.md b/.changeset/v0_2_0-cli.md new file mode 100644 index 0000000..d96d0b1 --- /dev/null +++ b/.changeset/v0_2_0-cli.md @@ -0,0 +1,5 @@ +--- +"@reaatech/a2a-reference-cli": minor +--- + +New package: the `a2a` scaffolding CLI generates an Express or Hono A2A agent with optional API-key/JWT auth and MCP bridge integration. diff --git a/.changeset/v0_2_0-core.md b/.changeset/v0_2_0-core.md new file mode 100644 index 0000000..257875c --- /dev/null +++ b/.changeset/v0_2_0-core.md @@ -0,0 +1,9 @@ +--- +"@reaatech/a2a-reference-core": minor +--- + +Spec-compliant Agent Card signatures and security schemes. + +- **Breaking:** Agent Card signatures now use the A2A spec JWS shape (`protected`/`signature`/`header`) — a JWS over the RFC 8785 (JCS) canonicalization of the card. Verification runs on `jose` (WebCrypto, edge-compatible) and supports RS/PS/ES 256/384/512 and EdDSA/Ed25519; keys are supplied directly or resolved from the protected header `jku`. +- **Breaking:** Security schemes now use the spec's OpenAPI-style `type` discriminator (`apiKey` | `http` | `oauth2` | `openIdConnect` | `mutualTLS`) with fully modeled OAuth `flows`, replacing the previous `scheme`/`httpScheme` shape. +- Add `auth-required` task-state transitions. diff --git a/.changeset/v0_2_0-observability.md b/.changeset/v0_2_0-observability.md new file mode 100644 index 0000000..c391f0b --- /dev/null +++ b/.changeset/v0_2_0-observability.md @@ -0,0 +1,5 @@ +--- +"@reaatech/a2a-reference-observability": minor +--- + +Add a pluggable telemetry-provider abstraction (tracer/meter/span with a no-op default) and a dedicated logger module with configurable transport. diff --git a/.changeset/v0_2_0-persistence.md b/.changeset/v0_2_0-persistence.md new file mode 100644 index 0000000..a49c031 --- /dev/null +++ b/.changeset/v0_2_0-persistence.md @@ -0,0 +1,5 @@ +--- +"@reaatech/a2a-reference-persistence": minor +--- + +Add `PostgresTaskStore`. Support principal-scoped `list()` across all stores so `tasks/list` totals and pagination respect the caller. Make `PostgresTaskStore.update()` transactional and persist history/artifacts. diff --git a/.changeset/v0_2_0-server.md b/.changeset/v0_2_0-server.md new file mode 100644 index 0000000..99bd72f --- /dev/null +++ b/.changeset/v0_2_0-server.md @@ -0,0 +1,5 @@ +--- +"@reaatech/a2a-reference-server": minor +--- + +Add health checks (`/healthz`, `/readyz`), an in-memory rate limiter, push-notification delivery wired into the event bus, a Redis SSE coordinator, extended Agent Card serving, and `tasks/sendSubscribe` over JSON-RPC. Add the `trustProxyHeaders` option controlling whether the rate-limit client IP is read from `X-Forwarded-For` (default off). `Retry-After` and the `retryAfter` body field are reported in seconds. diff --git a/AGENTS.md b/AGENTS.md index 4e62f35..6bb708c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,13 +8,14 @@ This is a **pnpm workspace monorepo** managed with Turborepo. ``` packages/ - core/ — Canonical A2A types, Zod schemas, error classes + core/ — Canonical A2A types, Zod schemas, error classes, signature verification server/ — A2A server framework (Express + Hono adapters) client/ — A2A client SDK - auth/ — Authentication strategies - persistence/ — Task store abstractions + auth/ — Authentication strategies (OAuth2, JWT, API key) + persistence/ — Task store abstractions (InMemory, Redis, Postgres, FileSystem) mcp-bridge/ — A2A ↔ MCP bidirectional adapter - observability/ — Logging, tracing, metrics + observability/ — Logging, tracing, metrics, telemetry + cli/ — Agent scaffolding CLI (a2a init) ``` ## Build System @@ -28,21 +29,14 @@ packages/ ### Common Commands ```bash -# Install all dependencies -pnpm install - -# Build everything -pnpm build - -# Run all tests -pnpm test - -# Lint & format -pnpm lint -pnpm lint:fix - -# Type-check without emit -pnpm typecheck +pnpm install # Install all dependencies +pnpm build # Build everything +pnpm test # Run all tests +pnpm test:coverage # Run tests with coverage (>90% required) +pnpm lint # Lint & format check +pnpm lint:fix # Auto-fix lint issues +pnpm typecheck # Type-check without emit +pnpm docs # Generate TypeDoc API docs ``` ## Coding Conventions @@ -53,19 +47,19 @@ pnpm typecheck 4. **Types:** Prefer `type` over `interface` for data shapes. Keep `interface` for class contracts. 5. **No `any`:** Biome is configured to error on `any`. Use `unknown` + narrowing instead. 6. **Exports:** Always provide ESM + CJS dual output with `types` condition first in `exports`. +7. **Coverage:** All packages must maintain >90% statement coverage. ## Adding a New Package 1. Create `packages//` with `package.json`, `tsconfig.json`, `src/index.ts` 2. Use `@reaatech/a2a-reference-core` for shared types. Do not duplicate schemas. -3. Add to `pnpm-workspace.yaml` if not under `packages/*` -4. Run `pnpm install` from the package directory +3. Run `pnpm install` from the package directory ## Testing - Unit tests live next to source files: `src/foo.test.ts` - E2E tests live in `e2e/` -- Always run `pnpm test` before committing +- Always run `pnpm test` and verify `pnpm test:coverage` before committing ## Protocol Compliance diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c9f7d76..3fe3b9c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -4,42 +4,38 @@ ## Overview -This monorepo implements Google's Agent-to-Agent (A2A) protocol in TypeScript with an enterprise-grade A2A↔MCP bridge adapter. +This monorepo implements Google's Agent-to-Agent (A2A) protocol in TypeScript with an enterprise-grade A2A↔MCP bridge adapter, CLI scaffolding, and production infrastructure. ## Package Boundaries ``` ┌─────────────────────────────────────────────────────────────┐ -│ A2A Server │ +│ A2A Server (Express + Hono) │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ -│ │ Express │ │ Hono │ │ AgentExecutor │ │ -│ │ Adapter │ │ Adapter │ │ (user logic) │ │ +│ │ Agent Card │ │ JSON-RPC │ │ AgentExecutor │ │ +│ │ / Health │ │ / SSE / PN │ │ (user logic) │ │ │ └─────────────┘ └─────────────┘ └─────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ A2A Client SDK │ -│ (discovery, task lifecycle, streaming) │ -└─────────────────────────────────────────────────────────────┘ - │ - ┌───────────────┼───────────────┐ - ▼ ▼ ▼ - ┌─────────┐ ┌──────────┐ ┌──────────┐ - │ Auth │ │Persistence│ │Observability│ - └─────────┘ └──────────┘ └──────────┘ - │ - ▼ + │ │ │ + ▼ ▼ ▼ +┌─────────┐ ┌──────────┐ ┌──────────┐ +│ Auth │ │Persistence│ │Observability│ +│ OAuth2 │ │InMemory │ │ Pino │ +│ JWT │ │Redis │ │ OTel │ +│ API Key │ │Postgres │ │ Metrics │ +│ │ │FileSystem │ │ │ +└─────────┘ └──────────┘ └──────────┘ + │ + ▼ ┌─────────────────────────────────────────────────────────────┐ │ A2A ↔ MCP Bridge │ -│ (bidirectional protocol adapter) │ +│ McpToolAdapter (A2A→MCP) + A2aAsMcpServer (MCP→A2A) │ └─────────────────────────────────────────────────────────────┘ ``` ## Data Flow ### Sync Task (JSON-RPC) - ``` Client ──POST /──► Server ──► AgentExecutor ──► TaskStore │ │ @@ -47,23 +43,34 @@ Client ──POST /──► Server ──► AgentExecutor ──► TaskStore ``` ### Streaming Task (SSE) - ``` -Client ──POST /──► Server ──► AgentExecutor +Client ──POST /tasks/sendSubscribe ──► Server ──► AgentExecutor │ - ├──► SSE: TaskStatusUpdateEvent - ├──► SSE: TaskArtifactUpdateEvent - └──► SSE: TaskStatusUpdateEvent (completed) + ├──► SSE: TaskStatusUpdateEvent (working) + ├──► SSE: TaskArtifactUpdateEvent (artifact data) + └──► SSE: TaskStatusUpdateEvent (completed/failed) ``` -### Bridge: A2A → MCP - +### Push Notifications ``` -A2A Task ──► BridgeAgent ──► tools/list ──► MCP Server - │ - ├──► Map skills ◄── tools schema +Agent ──► PushNotificationManager ──► HTTP POST ──► Client Webhook │ - └──► tools/call ──► MCP Server ──► Result ──► A2A Artifact + ├──► Bearer token auth header + └──► Retry with exponential backoff (3 attempts) +``` + +### Rate Limiting +``` +Request ──► RateLimiter.check() ──► allowed? ──► 200 (with X-RateLimit-Remaining) + │ + └──► denied? ──► 429 (with Retry-After) +``` + +## Health Checks +``` +GET /healthz ──► createHealthStatus() ──► Check task store + │ ├── Custom health checks + └──► JSON: { status: "ok", checks: [...] } ``` ## State Machine @@ -74,29 +81,30 @@ A2A Task ──► BridgeAgent ──► tools/list ──► MCP Server │ └────┬─────┘ │ │ │ │ │ ▼ │ - │ ┌─────────┐ │ - │ ┌────►│ working │────┐ │ - │ │ └────┬────┘ │ │ - │ │ │ │ │ - │ │ ▼ │ │ - │ │ ┌─────────────┐ │ │ - │ └───│ input-required│──┘ │ + │ ┌─────────┐ │ + │ ┌────►│ working │────┐ │ + │ │ └────┬────┘ │ │ + │ │ │ │ │ + │ │ ▼ │ │ + │ │ ┌─────────────┐ │ │ + │ └───│input-required│──┘ │ │ └──────┬──────┘ │ │ │ │ + │ ┌────▼─────┐ │ + │ │auth-required │ + │ └────┬─────┘ │ + │ │ │ │ ▼ │ │ ┌─────────────────┐ │ - └──────│ completed/failed │──────┘ - │ /canceled/rejected │ + └──────│ completed/failed│──────┘ + │ /canceled/rejected │ └─────────────────┘ ``` -## Technology Choices - -See [DEV_PLAN.md](./DEV_PLAN.md) Section 2.2 for the full technology stack rationale. - ## Extension Points -- **AuthStrategy** — implement custom auth schemes -- **TaskStore** — add new persistence backends -- **AgentExecutor** — implement agent logic -- **TransportAdapter** — add new HTTP frameworks +- **AuthStrategy** — implement custom auth schemes (OAuth2, JWT, API key) +- **TaskStore** — add new persistence backends (InMemory, Redis, Postgres, FileSystem) +- **AgentExecutor** — implement agent business logic +- **TelemetryProvider** — plug in OpenTelemetry or custom observability +- **RateLimiter** — custom key functions and window strategies diff --git a/README.md b/README.md index 0a3cd4b..e10fd90 100644 --- a/README.md +++ b/README.md @@ -6,230 +6,119 @@ [![pnpm](https://img.shields.io/badge/pnpm-10.22-orange)](https://pnpm.io/) [![Vitest](https://img.shields.io/badge/test-vitest-green)](https://vitest.dev/) -> **Production-ready TypeScript reference implementation of the [Agent-to-Agent (A2A) protocol](https://github.com/google/A2A)** — server framework, client SDK, and a bidirectional A2A ↔ MCP bridge, all in one monorepo. +> **Production-ready TypeScript reference implementation of the [Agent-to-Agent (A2A) protocol](https://github.com/google/A2A)** — server framework, client SDK, CLI scaffolding, and a bidirectional A2A ↔ MCP bridge. --- ## Why a2a-reference-ts? -The A2A protocol defines how AI agents discover each other, exchange messages, and manage task lifecycles. This project provides the canonical TypeScript implementation — battle-tested Zod schemas, pluggable server adapters, a type-safe client, and infrastructure for authentication, persistence, and observability. +The A2A protocol defines how AI agents discover each other, exchange messages, and manage task lifecycles. This project provides the canonical TypeScript implementation — battle-tested Zod schemas, pluggable server adapters, a type-safe client, and infrastructure for authentication, persistence, observability, and push notifications. It also includes a **bidirectional A2A ↔ MCP bridge**, allowing A2A agents to call MCP tools and MCP hosts to delegate work to A2A agents. If you're building interoperable agent systems, this is your starting point. --- -## Architecture +## Quick Start +### Using the CLI + +```bash +npx @reaatech/a2a-reference-cli init my-agent --hono +cd my-agent +npm install +npm run dev ``` -┌─────────────────────────────────────────────────────────────┐ -│ A2A Server │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ -│ │ Express │ │ Hono │ │ AgentExecutor │ │ -│ │ Adapter │ │ Adapter │ │ (your logic) │ │ -│ └─────────────┘ └─────────────┘ └─────────────────────┘ │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ A2A Client SDK │ -│ (discovery, task lifecycle, streaming) │ -└─────────────────────────────────────────────────────────────┘ - │ - ┌───────────────┼───────────────┐ - ▼ ▼ ▼ - ┌─────────┐ ┌──────────┐ ┌──────────────┐ - │ Auth │ │Persistence│ │ Observability │ - └─────────┘ └──────────┘ └──────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ A2A ↔ MCP Bridge │ -│ (bidirectional protocol adapter) │ -└─────────────────────────────────────────────────────────────┘ -``` -Data flows: [ARCHITECTURE.md](./ARCHITECTURE.md) covers sync JSON-RPC, SSE streaming, A2A → MCP tool calls, and the task state machine in depth. +Your agent is live at `http://localhost:3001` with a fully configured A2A server. + +### Hello Agent (Manual) + +```typescript +import { createA2AExpressApp } from "@reaatech/a2a-reference-server"; +import type { AgentExecutor } from "@reaatech/a2a-reference-server"; + +const app = createA2AExpressApp({ + agentCard: { + name: "Hello Agent", + description: "A friendly agent", + url: "http://localhost:3000", + version: "1.0.0", + protocolVersion: "0.3.0", + capabilities: { streaming: false }, + defaultInputModes: ["text"], + defaultOutputModes: ["text"], + skills: [{ id: "echo", name: "Echo", description: "Echo a message", tags: ["utility"], examples: ["Hello!"] }], + supportedInterfaces: [{ url: "http://localhost:3000", protocolBinding: "a2a", protocolVersion: "0.3.0" }], + }, + executor: { + async execute(context, eventBus) { + const text = context.message.parts.filter((p) => p.kind === "text").map((p) => p.text).join(" "); + await eventBus.emitStatusUpdate({ kind: "status", status: { state: "working" } }); + await eventBus.emitArtifactUpdate({ + kind: "artifact", + artifact: { parts: [{ kind: "text", text: `Hello! You said: "${text}"` }] }, + }); + await eventBus.emitStatusUpdate({ kind: "status", status: { state: "completed" }, final: true }); + }, + }, +}); + +app.listen(3000); +``` --- ## Features ### Core Protocol -- **Canonical types & validation** — Zod schemas derived directly from the A2A protocol specification (`proto/`) -- **Typed error hierarchy** — `A2AError` subclasses with error codes for every protocol-defined failure mode -- **JSON-RPC 2.0 transport** — Request/response and streaming via Server-Sent Events (SSE) +- Canonical types & validation via Zod schemas aligned with the A2A spec +- JSON-RPC 2.0 transport with SSE streaming +- Full task state machine: `submitted → working → input-required → completed/failed/canceled/rejected/auth-required` ### Server Framework -- **Dual HTTP adapters** — Express 5 and Hono, with a shared transport-agnostic core -- **Task lifecycle management** — Full state machine: `submitted → working → input-required → completed/failed/canceled` -- **Pluggable executors** — Implement `AgentExecutor` to define agent behavior; framework handles protocol wiring -- **Agent cards** — Automatic `/.well-known/agent.json` discovery endpoint - -### Client SDK -- **Type-safe discovery** — Fetch and validate agent cards with Zod -- **Task submission** — `sendMessage` (sync) and `sendSubscribe` (SSE streaming) -- **Streaming consumption** — `for await...of` iterator over task events +- **Dual adapters** — Express 5 and Hono with feature parity +- **Extended Agent Card** — `/.well-known/agent-card/extended` + RPC +- **Health checks** — `/healthz` and `/readyz` endpoints +- **Rate limiting** — Sliding window rate limiter +- **Push notifications** — Webhook delivery with retry and auth +- **Graceful shutdown** — Drains SSE connections, finishes in-flight tasks ### Security -- **Pluggable auth strategies** — OAuth2, JWT, and API key verification out of the box -- **Custom strategy support** — Implement `AuthStrategy` for bespoke auth schemes +- **OAuth2** — Client credentials, authorization code, and refresh token flows +- **JWT** — RS256 validation with JWKS caching and scope extraction +- **API Key** — Header-based validation with configurable key sets +- **Mutual TLS** — Schema support in Agent Card security schemes +- **Agent Card signatures** — RSA/ECDSA/Ed25519 signature verification ### Persistence -- **Task store abstraction** — Consistent interface across backends -- **Batteries included** — In-memory store (dev/testing) and Redis-backed store (production) +- **InMemoryTaskStore** — Development and testing +- **FileSystemTaskStore** — JSON file persistence with periodic flush +- **RedisTaskStore** — Multi-instance production with ioredis +- **PostgresTaskStore** — Relational storage with auto-migration and transactions ### A2A ↔ MCP Bridge -- **A2A → MCP** — Expose A2A skills as MCP tools, forward tool calls to agent executors -- **MCP → A2A** — Discover MCP tools as agent skills, invoke them from within A2A tasks -- **Zero-copy passthrough** — Message parts flow directly between protocols +- **A2A → MCP** — Expose A2A skills as MCP tools, forward tool calls +- **MCP → A2A** — Discover MCP tools as agent skills, invoke within A2A tasks ### Observability -- **Structured logging** — Pino-based JSON logging with configurable levels -- **Tracing hooks** — Instrumentation points for request lifecycle, task transitions, and errors -- **Metrics** — Prometheus-compatible counters, histograms, and gauges - ---- - -## Installation - -### Using published packages - -All packages are published under `@reaatech`: - -```bash -# Core types and schemas -pnpm add @reaatech/a2a-reference-core - -# Server framework -pnpm add @reaatech/a2a-reference-server - -# Client SDK -pnpm add @reaatech/a2a-reference-client - -# Authentication -pnpm add @reaatech/a2a-reference-auth - -# Task persistence -pnpm add @reaatech/a2a-reference-persistence - -# A2A ↔ MCP bridge -pnpm add @reaatech/a2a-reference-mcp-bridge - -# Observability -pnpm add @reaatech/a2a-reference-observability -``` - -### Local development - -```bash -git clone https://github.com/reaatech/a2a-reference-ts.git -cd a2a-reference-ts -pnpm install # install all workspace dependencies -pnpm build # build all packages -pnpm test # run the full test suite -pnpm lint # lint and check formatting -pnpm typecheck # type-check without emitting -``` - ---- - -## Quick Start - -### Hello Agent (Express) - -```typescript -import { createA2AExpressApp } from "@reaatech/a2a-reference-server"; -import type { AgentExecutor, ExecutionContext, ExecutionEventBus } from "@reaatech/a2a-reference-server"; - -const agentCard = { - name: "Hello Agent", - description: "A friendly agent that echoes your message back", - url: "http://localhost:3000", - version: "1.0.0", - protocolVersion: "0.3.0", - capabilities: { streaming: false, pushNotifications: false, stateTransitionHistory: false }, - defaultInputModes: ["text/plain"], - defaultOutputModes: ["text/plain"], - skills: [{ id: "echo", name: "Echo", description: "Echo a message back", tags: ["echo"], examples: ["Hello!"] }], - supportedInterfaces: [{ url: "http://localhost:3000", protocolBinding: "a2a", protocolVersion: "0.3.0" }], -}; - -const executor: AgentExecutor = { - async execute(context: ExecutionContext, eventBus: ExecutionEventBus) { - const text = context.message.parts - .filter((p) => p.kind === "text") - .map((p) => p.text) - .join(" "); - - eventBus.emitStatusUpdate({ kind: "status", status: { state: "working" } }); - eventBus.emitArtifactUpdate({ - kind: "artifact", - artifact: { name: "response", parts: [{ kind: "text", text: `Hello! You said: "${text}"` }] }, - }); - eventBus.emitStatusUpdate({ kind: "status", status: { state: "completed" } }); - }, -}; - -const app = createA2AExpressApp({ agentCard, executor }); -app.listen(3000, () => console.log("Agent running at http://localhost:3000")); -``` - -### Client Usage - -```typescript -import { A2AClient } from "@reaatech/a2a-reference-client"; - -const client = new A2AClient({ baseUrl: "http://localhost:3000" }); - -// Sync request -const task = await client.sendMessage({ - messageId: crypto.randomUUID(), - role: "user", - parts: [{ kind: "text", text: "Hello!" }], -}); -console.log(task.artifacts?.[0]?.parts); - -// Streaming (SSE) -for await (const event of client.sendSubscribe({ - messageId: crypto.randomUUID(), - role: "user", - parts: [{ kind: "text", text: "Count to 5" }], -})) { - if (event.kind === "artifact") { - console.log(event.artifact.parts); - } -} -``` +- **Structured logging** — Pino with correlation IDs +- **OpenTelemetry hooks** — Pluggable `TelemetryProvider` for tracing and metrics +- **Task telemetry** — Counters, duration histograms, and span wrapping --- ## Packages -| Package | Description | Dependencies | -| ------- | ----------- | ------------ | -| [`core`](./packages/core) | Canonical A2A types, Zod schemas, error classes | `zod` | -| [`server`](./packages/server) | A2A server framework (Express + Hono) | `core`, `auth`, `persistence`, `observability` | -| [`client`](./packages/client) | A2A client SDK | `core` | -| [`auth`](./packages/auth) | Pluggable authentication strategies | `jose` | -| [`persistence`](./packages/persistence) | Task store abstractions (in-memory + Redis) | `core`, `ioredis` | -| [`mcp-bridge`](./packages/mcp-bridge) | A2A ↔ MCP bidirectional adapter | `core`, `client`, `observability`, `@modelcontextprotocol/sdk` | -| [`observability`](./packages/observability) | Structured logging, tracing, and metrics | `pino` | - ---- - -## Examples - -Runnable examples live in the [`examples/`](./examples/) directory: - -| Example | Description | +| Package | Description | | ------- | ----------- | -| [`01-hello-agent`](./examples/01-hello-agent) | Minimal echo agent — the simplest possible A2A server | -| [`02-task-streaming`](./examples/02-task-streaming) | SSE streaming with progress updates and a client-side consumer | -| [`03-multi-agent-workflow`](./examples/03-multi-agent-workflow) | Orchestrator pattern — one agent delegates to specialist agents via the client SDK | -| [`04-mcp-bridge`](./examples/04-mcp-bridge) | A2A ↔ MCP bridge in both directions | -| [`05-auth-workflow`](./examples/05-auth-workflow) | Auth-protected agent with API key and JWT strategies | - -Each example has its own `README.md` with run instructions. +| [`core`](./packages/core) | Canonical A2A types, Zod schemas, error classes, signature verification | +| [`server`](./packages/server) | A2A server framework (Express + Hono) | +| [`client`](./packages/client) | A2A client SDK | +| [`auth`](./packages/auth) | Pluggable authentication (OAuth2, JWT, API key) | +| [`persistence`](./packages/persistence) | Task stores (InMemory, FileSystem, Redis, PostgreSQL) | +| [`mcp-bridge`](./packages/mcp-bridge) | A2A ↔ MCP bidirectional adapter | +| [`observability`](./packages/observability) | Logging, telemetry, and metrics hooks | +| [`cli`](./packages/cli) | Agent scaffolding CLI tool | --- @@ -237,13 +126,12 @@ Each example has its own `README.md` with run instructions. | Document | Content | | -------- | ------- | -| [ARCHITECTURE.md](./ARCHITECTURE.md) | System design, package boundaries, data flows, state machine | -| [CONTRIBUTING.md](./CONTRIBUTING.md) | Development setup, conventional commits, PR process, releasing | -| [AGENTS.md](./AGENTS.md) | Coding conventions, tooling, and conventions for AI agents | -| [docs/deployment.md](./docs/deployment.md) | Production deployment patterns | -| [docs/authentication.md](./docs/authentication.md) | Auth strategy deep dive | -| [docs/protocol-compliance.md](./docs/protocol-compliance.md) | A2A protocol compliance matrix | +| [ARCHITECTURE.md](./ARCHITECTURE.md) | System design, package boundaries, data flows | +| [docs/authentication.md](./docs/authentication.md) | Auth strategy deep dive (OAuth2, JWT, API key) | +| [docs/deployment.md](./docs/deployment.md) | Production deployment, health checks, rate limiting | +| [docs/protocol-compliance.md](./docs/protocol-compliance.md) | A2A spec compliance matrix | | [docs/bridge-deep-dive.md](./docs/bridge-deep-dive.md) | A2A ↔ MCP bridge internals | +| [docs/edge-deployment.md](./docs/edge-deployment.md) | Serverless (CF Workers, Deno, Bun) | --- @@ -251,29 +139,14 @@ Each example has its own `README.md` with run instructions. | Layer | Technology | | ----- | ---------- | -| Language | TypeScript 5.8 (strict mode) | -| Runtime | Node.js ≥ 18 | +| Language | TypeScript 5.8 (strict, no `any`) | +| Runtime | Node.js ≥ 20 | | Package manager | pnpm 10 (workspaces) | | Build | tsup + Turborepo | | Lint & format | Biome | -| Testing | Vitest | +| Testing | Vitest (>90% coverage) | | Validation | Zod | | Logging | Pino | -| Versioning | Changesets | - ---- - -## Contributing - -Contributions are welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full workflow: - -1. Fork the repo, create a feature branch -2. Write code with tests (`vitest`), lint with `biome` -3. Run `pnpm lint && pnpm test` before committing -4. Use [Conventional Commits](https://www.conventionalcommits.org/) -5. Open a PR - -Coding conventions and project guidance live in [AGENTS.md](./AGENTS.md). --- diff --git a/biome.json b/biome.json index d7cd1be..ccc2ea9 100644 --- a/biome.json +++ b/biome.json @@ -3,6 +3,18 @@ "files": { "ignore": ["dist", ".turbo", "node_modules", "coverage"] }, + "overrides": [ + { + "include": ["*.test.ts", "*.test.tsx"], + "linter": { + "rules": { + "suspicious": { + "noExplicitAny": "off" + } + } + } + } + ], "organizeImports": { "enabled": true }, diff --git a/docs/authentication.md b/docs/authentication.md index f97aa8a..98513bc 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -14,28 +14,57 @@ Use `NoneStrategy` for local development or when the agent is intentionally publ import { ApiKeyStrategy } from '@reaatech/a2a-reference-auth'; const auth = new ApiKeyStrategy({ keys: new Set(['secret']) }); ``` +Validates the `x-api-key` header against a configured set of keys. Supports custom header names via `headerName` option. ### JWT (RS256) ```ts import { JwtStrategy } from '@reaatech/a2a-reference-auth'; const auth = new JwtStrategy({ publicKey: '-----BEGIN PUBLIC KEY-----...' }); ``` +Validates Bearer tokens using a static RS256 public key. Supports `issuer` and `audience` verification. ### JWT with JWKS ```ts const auth = new JwtStrategy({ jwksUri: 'https://auth.example.com/.well-known/jwks.json' }); ``` +Fetches verification keys dynamically from a JWKS endpoint. JWKS is cached for the lifetime of the strategy instance. -## OAuth2 +### OAuth2 -`OAuth2Strategy` is **not yet implemented**. If you need OAuth2 Bearer token validation, use `JwtStrategy` with a `jwksUri` instead. It handles RS256 JWTs issued by most OAuth2 providers. +```ts +import { OAuth2Strategy } from '@reaatech/a2a-reference-auth'; + +const auth = new OAuth2Strategy({ + issuer: 'https://auth.example.com', + tokenEndpoint: 'https://auth.example.com/oauth/token', + clientId: 'my-agent', + clientSecret: 'secret', + scopes: ['a2a:tasks'], + jwksUri: 'https://auth.example.com/.well-known/jwks.json', +}); +``` + +Full OAuth2 implementation supporting: + +- **Token validation** — Verifies Bearer tokens against the configured issuer using JWKS or a static public key +- **Client credentials grant** — `exchangeClientCredentials()` for server-to-server auth +- **Authorization code grant** — `exchangeAuthorizationCode()` for user-facing flows with PKCE support +- **Token refresh** — `refreshAccessToken()` for long-lived sessions +- **Fetch timeout** — Configurable `fetchTimeoutMs` (default 10s) prevents hanging on unreachable providers ## Wiring into Server + ```ts import { createA2AExpressApp } from '@reaatech/a2a-reference-server'; const app = createA2AExpressApp({ agentCard, executor, authStrategy: auth }); ``` +For Hono: +```ts +import { createA2AHonoApp } from '@reaatech/a2a-reference-server'; +const app = createA2AHonoApp({ agentCard, executor, authStrategy: auth }); +``` + ## Tenant Isolation -Tenant isolation (path-param based `tenant` scoping) is **not yet implemented**. All tasks currently share a single namespace per deployment. +The `Task` schema includes `principal` and `tenantId` fields. When auth is enabled, tasks are automatically scoped to the authenticated principal. The `tasks/list` and `tasks/get` handlers filter results to only include tasks belonging to the current principal. diff --git a/docs/deployment.md b/docs/deployment.md index 1042514..5219243 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -9,6 +9,7 @@ cd docker && docker compose up Services: - `hello-agent` — Example A2A agent on port 3000 - `redis` — Task store backend +- `postgres` — Optional relational task store backend - `prometheus` — Metrics scraping - `grafana` — Metrics dashboards @@ -19,9 +20,128 @@ Services: | `NODE_ENV` | `development` | Runtime environment | | `PORT` | `3000` | HTTP server port | | `LOG_LEVEL` | `info` | Pino log level | -| `REDIS_URL` | — | Redis connection string (optional) | -| `JWKS_URI` | — | JWKS endpoint for JWT validation (optional) | -| `API_KEYS` | — | Comma-separated API keys (optional) | +| `REDIS_URL` | — | Redis connection string | +| `DATABASE_URL` | — | PostgreSQL connection string | +| `JWKS_URI` | — | JWKS endpoint for JWT validation | +| `API_KEYS` | — | Comma-separated API keys | + +## Server Options + +### Health Checks + +Both Express and Hono adapters expose `/healthz` and `/readyz` endpoints: + +```ts +const app = createA2AExpressApp({ + agentCard, + executor, + healthChecks: [ + { + name: 'external-api', + check: async () => { + const ok = await pingExternalService(); + return { status: ok ? 'ok' : 'degraded' }; + }, + }, + ], +}); +``` + +- `/healthz` — Returns overall health status: `ok` (200), `degraded` (200), or `unhealthy` (503) +- `/readyz` — Returns `ok` (200) or `unhealthy` (503) for readiness probes + +### Rate Limiting + +```ts +import { RateLimiter } from '@reaatech/a2a-reference-server'; + +const app = createA2AExpressApp({ + agentCard, + executor, + rateLimiter: new RateLimiter({ + windowMs: 60_000, // 1 minute window + maxRequests: 100, // 100 requests per window + }), +}); +``` + +Rate-limited requests receive a `429 Too Many Requests` response with a `Retry-After` header (seconds) and an `X-RateLimit-Remaining` header. + +By default the client IP is taken from the socket peer address. When running behind a trusted load balancer or reverse proxy, set `trustProxyHeaders: true` so the limiter keys off `X-Forwarded-For` instead: + +```ts +const app = createA2AExpressApp({ + agentCard, + executor, + rateLimiter: new RateLimiter({ windowMs: 60_000, maxRequests: 100 }), + trustProxyHeaders: true, // only enable behind a proxy that overwrites X-Forwarded-For +}); +``` + +Do not enable this when the server is directly reachable — clients could spoof the header to evade or poison rate limits. + +### Push Notifications + +Enable push notification webhooks by setting `capabilities.pushNotifications: true` in your Agent Card: + +```ts +const agentCard = { + capabilities: { pushNotifications: true }, + // ... +}; +``` + +Clients can register webhook URLs via `tasks/pushNotification/set` to receive task status and artifact updates as HTTP POST requests. + +### Extended Agent Card + +Serve additional agent metadata at `/.well-known/agent-card/extended`: + +```ts +const app = createA2AExpressApp({ + agentCard, + executor, + extendedAgentCard: { + description: 'Extended capabilities for this agent', + supportedFeatures: ['batch-processing', 'custom-schemas'], + }, +}); +``` + +Can also be queried via the `tasks/extendedAgentCard` JSON-RPC method. + +## Task Stores + +The server supports multiple persistence backends: + +| Store | Use Case | +|-------|----------| +| `InMemoryTaskStore` | Development and testing | +| `FileSystemTaskStore` | Single-instance production (persists to JSON) | +| `RedisTaskStore` | Multi-instance production with Redis | +| `PostgresTaskStore` | Audit-heavy deployments with SQL | + +```ts +import { PostgresTaskStore } from '@reaatech/a2a-reference-persistence'; +import pg from 'pg'; + +const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); +const taskStore = new PostgresTaskStore({ pool }); +await taskStore.initialize(); +``` + +## SSE with Redis Pub/Sub + +For horizontal scaling with SSE streaming, use `RedisSseCoordinator` to broadcast events across instances: + +```ts +import { RedisSseCoordinator } from '@reaatech/a2a-reference-server'; +import { Redis } from 'ioredis'; + +const redis = new Redis(process.env.REDIS_URL); +const coordinator = new RedisSseCoordinator({ redis }); +await coordinator.connect(); +``` ## Kubernetes @@ -29,8 +149,6 @@ Services: > > A Helm chart and sample K8s manifests will be added in a future release. -## Cloudflare Workers +## Edge Deployment -> **Coming soon.** -> -> A Workers adapter and wrangler configuration example will be added in a future release. +See [edge-deployment.md](./edge-deployment.md) for deployment guides for Cloudflare Workers, Deno Deploy, and Bun. diff --git a/docs/edge-deployment.md b/docs/edge-deployment.md new file mode 100644 index 0000000..cc3b03d --- /dev/null +++ b/docs/edge-deployment.md @@ -0,0 +1,69 @@ +# Edge Runtime Deployment + +This guide covers deploying A2A agents on edge runtimes such as Cloudflare Workers, Deno Deploy, and Bun. + +## Architecture Overview + +The A2A server framework provides a Hono adapter (`createA2AHonoApp`) that is naturally suited for edge deployment, as Hono supports multiple runtimes including Cloudflare Workers, Deno, and Bun. + +``` +┌─────────────────────────────────────────┐ +│ Edge Runtime │ +│ ┌───────────────────────────────────┐ │ +│ │ Hono App (.well-known) │ │ +│ │ Agent Card / Extended Card │ │ +│ │ Health Checks (healthz/readyz) │ │ +│ └───────────────────────────────────┘ │ +│ ┌───────────────────────────────────┐ │ +│ │ JSON-RPC Endpoint (POST /) │ │ +│ │ tasks/send, tasks/get, ... │ │ +│ └───────────────────────────────────┘ │ +│ ┌───────────────────────────────────┐ │ +│ │ SSE Streaming │ │ +│ │ tasks/sendSubscribe, subscribe │ │ +│ └───────────────────────────────────┘ │ +│ ┌───────────────────────────────────┐ │ +│ │ Task Store (KV / D1 / R2) │ │ +│ └───────────────────────────────────┘ │ +└─────────────────────────────────────────┘ +``` + +> **Note:** Edge-specific task stores (Cloudflare Workers KV/D1, Deno KV, Bun SQLite) are planned for future releases. Currently, use `InMemoryTaskStore` for edge deployments or a remote `PostgresTaskStore`. + +> **Important:** `InMemoryTaskStore` uses in-memory `Map` objects which do **not** persist across Cloudflare Worker requests or Deno Deploy isolates. In stateless edge runtimes, each request may hit a different instance with no shared memory, making `InMemoryTaskStore` effectively volatile and only suitable for **development/testing**. For production edge deployments, use a remote `PostgresTaskStore` (e.g., via `@neondatabase/serverless`) or a custom task store backed by durable storage. + +## Limitations + +| Feature | Express | Hono | Edge (CF/Deno/Bun) | +|---------|---------|------|-------------------| +| SSE Streaming | ✅ | ✅ | ✅ (ReadableStream) | +| InMemoryTaskStore | ✅ | ✅ | ⚠️ (volatile) | +| FileSystemTaskStore | ✅ | ✅ | ❌ (no FS access) | +| RedisTaskStore | ✅ | ✅ | ⚠️ (via Upstash) | +| PostgresTaskStore | ✅ | ✅ | ⚠️ (via neon/serverless) | +| Rate Limiting | ✅ | ✅ | ✅ | +| Health Checks | ✅ | ✅ | ✅ | +| Auth (API Key/JWT/OAuth2) | ✅ | ✅ | ✅ | +| Push Notifications | ✅ | ✅ | ⚠️ (via webhooks) | + +## Recommended Task Stores for Edge + +Edge-specific task stores are planned for future releases. For current edge deployments, use `InMemoryTaskStore` (volatile) or a remote `PostgresTaskStore` via a serverless-compatible driver (e.g. `@neondatabase/serverless`). + +## Build Configuration + +For edge deployments, ensure your build targets the correct runtime: + +```jsonc +// tsconfig.json (Cloudflare Workers) +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["@cloudflare/workers-types"] + } +} +``` + +For Hono-based deployments, ensure you're using the Web-standard `fetch` API rather than Node.js-specific APIs (e.g., avoid `fs`, `net`, `dgram` modules). diff --git a/docs/protocol-compliance.md b/docs/protocol-compliance.md index ba1729e..046d9c0 100644 --- a/docs/protocol-compliance.md +++ b/docs/protocol-compliance.md @@ -2,13 +2,18 @@ | Feature | Status | Notes | |---------|--------|-------| -| Agent Card discovery | ✅ | `/.well-known/agent.json` | -| JSON-RPC 2.0 methods | ✅ | `tasks/send`, `tasks/get`, `tasks/list`, `tasks/cancel` | -| SSE streaming | ✅ | `tasks/sendSubscribe`, `tasks/subscribe` | -| Task state machine | ✅ | All states + transitions | -| Authentication | ✅ | API key, JWT, JWKS | -| Push notifications | 🚧 | Schema present, not fully wired | -| Extended agent card | 🚧 | Schema present, endpoint stubbed | +| Agent Card discovery | ✅ | `/.well-known/agent.json` and `/.well-known/agent-card` | +| Extended Agent Card | ✅ | `GET /.well-known/agent-card/extended` and `tasks/extendedAgentCard` RPC | +| JSON-RPC 2.0 methods | ✅ | `tasks/send`, `tasks/get`, `tasks/list`, `tasks/cancel`, `tasks/sendSubscribe` | +| SSE streaming | ✅ | `tasks/sendSubscribe` (POST), `tasks/subscribe` (GET) | +| Task state machine | ✅ | All 8 states + transitions: submitted, working, input-required, completed, failed, canceled, rejected, auth-required | +| Authentication | ✅ | API key, JWT (RS256), JWKS, OAuth2 (client credentials + authorization code) | +| Mutual TLS | ✅ | `MutualTlsSecurityScheme` defined in Zod schemas | +| Agent Card signatures | ✅ | `AgentCardSignatureSchema` with RSA/ECDSA/Ed25519 verification | +| Push notifications | ✅ | Webhook delivery via `tasks/pushNotification/set`, `get`, `list`, `delete` | +| Push notification config | ✅ | `TaskPushNotificationConfigSchema` with bearer token and apiKey auth | +| Rate limiting | ✅ | Sliding window with configurable max/window | +| Health checks | ✅ | `/healthz` and `/readyz` endpoints (both adapters) | +| Task persistence | ✅ | InMemory, FileSystem, Redis, PostgreSQL | +| Multi-instance SSE | ✅ | Redis pub/sub coordination via `RedisSseCoordinator` | | gRPC binding | ❌ | Not planned for v1 | - -> **Note:** The `auth-required` task state and the corresponding request/response schemas have been added to `packages/core`. diff --git a/package.json b/package.json index d33801f..5556add 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "lint:fix": "biome check --write .", "format": "biome format --write .", "typecheck": "tsc --noEmit -p tsconfig.typecheck.json", + "docs": "typedoc", + "docs:serve": "typedoc --watch", "clean": "turbo run clean && rm -rf node_modules", "changeset": "changeset", "version-packages": "changeset version", diff --git a/packages/auth/README.md b/packages/auth/README.md index bd3a3fa..02c5f55 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -1,228 +1,58 @@ # @reaatech/a2a-reference-auth -[![npm version](https://img.shields.io/npm/v/@reaatech/a2a-reference-auth.svg)](https://www.npmjs.com/package/@reaatech/a2a-reference-auth) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/reaatech/a2a-reference-ts/blob/main/LICENSE) -[![CI](https://img.shields.io/github/actions/workflow/status/reaatech/a2a-reference-ts/ci.yml?branch=main&label=CI)](https://github.com/reaatech/a2a-reference-ts/actions/workflows/ci.yml) +Pluggable authentication and authorization for A2A agents. -> **Status:** Pre-1.0 — APIs may change in minor versions. Pin to a specific version in production. +## Strategies -Pluggable authentication strategies for A2A agents. Provides ready-to-use implementations for API key verification, JWT validation (including JWKS), and no-op pass-through — all behind a single `AuthStrategy` interface. - -## Installation - -```bash -npm install @reaatech/a2a-reference-auth -# or -pnpm add @reaatech/a2a-reference-auth -``` - -## Feature Overview - -- **API key strategy** — validate against a whitelist of pre-shared keys -- **JWT strategy** — verify RS256 tokens against a static public key or remote JWKS endpoint -- **No-op strategy** — pass-through for development or gateway-authenticated setups -- **Single-method interface** — all strategies implement `authenticate(ctx): Promise` -- **Zero cross-package dependencies** — self-contained, usable outside the A2A ecosystem - -## Quick Start - -```typescript -import { ApiKeyStrategy, JwtStrategy, NoneStrategy } from "@reaatech/a2a-reference-auth"; - -// API key verification -const apiKeyAuth = new ApiKeyStrategy({ - keys: new Set(["sk-abc123", "sk-def456"]), -}); - -// JWT with a static public key -const jwtAuth = new JwtStrategy({ - publicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----", - issuer: "https://auth.example.com", - audience: "a2a", -}); - -// JWT with remote JWKS (key rotation) -const jwksAuth = new JwtStrategy({ - jwksUri: "https://auth.example.com/.well-known/jwks.json", - issuer: "https://auth.example.com", -}); - -// No-op (development or gateway auth) -const noAuth = new NoneStrategy(); -``` - -## API Reference - -### Core Interfaces - -#### `AuthStrategy` - -The contract that all strategies implement: - -```typescript -interface AuthStrategy { - authenticate(context: AuthContext): Promise; -} -``` - -#### `AuthContext` - -Input provided by the server framework: - -```typescript -interface AuthContext { - headers: Record; -} -``` - -#### `AuthResult` - -The result of an authentication attempt: - -```typescript -interface AuthResult { - authenticated: boolean; - principal?: string; // Identity of the caller - scopes?: string[]; // OAuth-style scope tokens - reason?: string; // Human-readable failure reason -} +### NoneStrategy +For development and open agents: +```ts +const auth = new NoneStrategy(); ``` -### `ApiKeyStrategy` - -Validates a pre-shared API key from a configurable request header. - -```typescript -import { ApiKeyStrategy, type ApiKeyStrategyOptions } from "@reaatech/a2a-reference-auth"; - -const auth = new ApiKeyStrategy({ - keys: new Set(["secret-key-1", "secret-key-2"]), - headerName: "x-api-key", // default -}); +### ApiKeyStrategy +Header-based API key validation: +```ts +const auth = new ApiKeyStrategy({ keys: new Set(['sk-1234']) }); ``` -#### `ApiKeyStrategyOptions` - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `keys` | `Set` | (required) | Whitelist of valid API keys | -| `headerName` | `string` | `"x-api-key"` | Header to check for the key | - -#### Authentication Logic - -- Missing header → `{ authenticated: false, reason: "missing api key" }` -- Key not in set → `{ authenticated: false, reason: "invalid api key" }` -- Valid key → `{ authenticated: true, principal: "api-key:..." }` - -### `JwtStrategy` - -Validates RS256-signed JWTs from the `Authorization: Bearer ` header. - -```typescript -import { JwtStrategy, type JwtStrategyOptions } from "@reaatech/a2a-reference-auth"; - -// Static public key +### JwtStrategy +RS256 JWT Bearer token validation with JWKS support: +```ts const auth = new JwtStrategy({ - publicKey: "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----", - issuer: "https://auth.example.com", - audience: "my-app", -}); - -// Remote JWKS -const authWithJwks = new JwtStrategy({ - jwksUri: "https://auth.example.com/.well-known/jwks.json", - issuer: "https://auth.example.com", + issuer: 'https://auth.example.com', + audience: 'my-agent', + jwksUri: 'https://auth.example.com/.well-known/jwks.json', }); ``` -#### `JwtStrategyOptions` - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `publicKey` | `string` | — | SPKI PEM public key for RS256 verification | -| `jwksUri` | `string` | — | URL to a JWKS endpoint (supports key rotation via `kid`) | -| `issuer` | `string` | — | Expected `iss` claim | -| `audience` | `string` | — | Expected `aud` claim | - -At least one of `publicKey` or `jwksUri` must be provided. - -#### Authentication Logic - -- Missing `Authorization` header → `{ authenticated: false, reason: "missing token" }` -- Malformed header → `{ authenticated: false, reason: "invalid format" }` -- No verification key configured → `{ authenticated: false, reason: "no verification key configured" }` -- Verification failure → `{ authenticated: false, reason: "verification failed: " }` -- Success → `{ authenticated: true, principal: "", scopes: ["scope1", "scope2"] }` - -The `sub` JWT claim is extracted as `principal`. The `scope` claim can be a space-delimited string or a string array. - -### `NoneStrategy` - -A no-op pass-through strategy that accepts all requests. - -```typescript -import { NoneStrategy } from "@reaatech/a2a-reference-auth"; - -const auth = new NoneStrategy(); -// Always returns: { authenticated: true, principal: "anonymous" } -``` - -Useful for: -- Local development -- When authentication is handled by an API gateway or reverse proxy -- Bootstrapping before adding real auth - -## Integration with the Server - -```typescript -import { createA2AExpressApp } from "@reaatech/a2a-reference-server"; -import { ApiKeyStrategy } from "@reaatech/a2a-reference-auth"; - -const authStrategy = new ApiKeyStrategy({ - keys: new Set([process.env.A2A_API_KEY]), -}); - -const app = createA2AExpressApp({ - agentCard, - executor, - authStrategy, // <-- plug in here +### OAuth2Strategy +Full OAuth2 flow for production deployments: +```ts +const auth = new OAuth2Strategy({ + issuer: 'https://auth.example.com', + tokenEndpoint: 'https://auth.example.com/oauth/token', + clientId: 'my-agent', + clientSecret: process.env.CLIENT_SECRET, + scopes: ['a2a:tasks'], + jwksUri: 'https://auth.example.com/.well-known/jwks.json', }); -app.listen(3000); -``` - -The server framework calls `authenticate()` on every request. Authenticated tasks have their `principal` field set to the caller's identity, enabling per-user task isolation in `tasks/list` and `tasks/get`. - -## Creating a Custom Strategy - -Implement the `AuthStrategy` interface: +// Server-to-server auth +const { accessToken } = await auth.exchangeClientCredentials(); -```typescript -import type { AuthStrategy, AuthContext, AuthResult } from "@reaatech/a2a-reference-auth"; +// User-facing auth +const { accessToken, refreshToken } = await auth.exchangeAuthorizationCode(code, redirectUri); -class MyCustomAuth implements AuthStrategy { - async authenticate(context: AuthContext): Promise { - const token = context.headers["x-custom-token"]; - - if (!token) { - return { authenticated: false, reason: "missing custom token" }; - } +// Token refresh +const { accessToken } = await auth.refreshAccessToken(refreshToken); +``` - const valid = await this.validateToken(token); - if (!valid) { - return { authenticated: false, reason: "invalid custom token" }; - } +## Utilities - return { authenticated: true, principal: token }; - } +```ts +import { extractScopes } from '@reaatech/a2a-reference-auth'; - private async validateToken(token: string): Promise { - // Your validation logic here - return true; - } -} +const scopes = extractScopes({ scope: 'read write admin' }); +// ['read', 'write', 'admin'] ``` - -## License - -[MIT](https://github.com/reaatech/a2a-reference-ts/blob/main/LICENSE) diff --git a/packages/auth/package.json b/packages/auth/package.json index 4e3beae..ad6cce8 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -35,7 +35,10 @@ "clean": "rm -rf dist" }, "dependencies": { - "jose": "^6.0.10" + "@reaatech/a2a-reference-core": "workspace:*", + "@reaatech/a2a-reference-observability": "workspace:*", + "jose": "^6.0.10", + "zod": "^3.24.2" }, "devDependencies": { "@types/node": "^25.6.0", diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 12461ec..542af25 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -4,3 +4,10 @@ export type { ApiKeyStrategyOptions } from './api-key.js'; export { JwtStrategy } from './jwt.js'; export type { JwtStrategyOptions } from './jwt.js'; export { NoneStrategy } from './none.js'; +export { OAuth2Strategy } from './oauth2.js'; +export type { + OAuth2StrategyOptions, + OAuth2ClientCredentialsGrantRequest, + OAuth2AuthorizationCodeGrantRequest, +} from './oauth2.js'; +export { extractScopes } from './utils.js'; diff --git a/packages/auth/src/jwt.ts b/packages/auth/src/jwt.ts index 671d1fa..08a651c 100644 --- a/packages/auth/src/jwt.ts +++ b/packages/auth/src/jwt.ts @@ -1,15 +1,38 @@ +import { A2AError } from '@reaatech/a2a-reference-core'; +import { type Logger, defaultLogger } from '@reaatech/a2a-reference-observability'; import { type JWTVerifyResult, createRemoteJWKSet, importSPKI, jwtVerify } from 'jose'; import type { AuthContext, AuthResult, AuthStrategy } from './strategy.js'; +import { extractScopes } from './utils.js'; export interface JwtStrategyOptions { issuer?: string; audience?: string; publicKey?: string; jwksUri?: string; + algorithm?: string; + logger?: Logger; } export class JwtStrategy implements AuthStrategy { - constructor(private options: JwtStrategyOptions) {} + private jwksCache: ReturnType | null = null; + + private algorithm: string; + private logger: Logger; + + constructor(private options: JwtStrategyOptions) { + this.algorithm = options.algorithm ?? 'RS256'; + this.logger = options.logger ?? defaultLogger; + if (options.jwksUri) { + try { + this.jwksCache = createRemoteJWKSet(new URL(options.jwksUri)); + } catch (err) { + throw new A2AError( + 'InvalidJwksUri', + `Invalid jwksUri: ${options.jwksUri} — must be a valid URL`, + ); + } + } + } async authenticate(context: AuthContext): Promise { const authHeader = context.headers.authorization; @@ -19,13 +42,13 @@ export class JwtStrategy implements AuthStrategy { return { authenticated: false, reason: 'missing token' }; } - if (!headerValue.startsWith('Bearer ')) { + if (!headerValue.toLowerCase().startsWith('bearer ')) { return { authenticated: false, reason: 'invalid format' }; } - const token = headerValue.slice(7); + const token = headerValue.slice(7).trim(); - if (!this.options.publicKey && !this.options.jwksUri) { + if (!this.options.publicKey && !this.jwksCache) { return { authenticated: false, reason: 'no verification key configured' }; } @@ -33,14 +56,13 @@ export class JwtStrategy implements AuthStrategy { let result: JWTVerifyResult; if (this.options.publicKey) { - const key = await importSPKI(this.options.publicKey, 'RS256'); + const key = await importSPKI(this.options.publicKey, this.algorithm); result = await jwtVerify(token, key, { issuer: this.options.issuer, audience: this.options.audience, }); - } else if (this.options.jwksUri) { - const jwks = createRemoteJWKSet(new URL(this.options.jwksUri)); - result = await jwtVerify(token, jwks, { + } else if (this.jwksCache) { + result = await jwtVerify(token, this.jwksCache, { issuer: this.options.issuer, audience: this.options.audience, }); @@ -49,22 +71,11 @@ export class JwtStrategy implements AuthStrategy { } const sub = result.payload.sub ?? 'unknown'; - const scopes = this.extractScopes(result.payload); + const scopes = extractScopes(result.payload as Record); return { authenticated: true, principal: sub, scopes }; } catch (err) { - const message = err instanceof Error ? err.message : String(err); - return { authenticated: false, reason: `verification failed: ${message}` }; - } - } - - private extractScopes(payload: Record): string[] | undefined { - const scope = payload.scope; - if (typeof scope === 'string') { - return scope.split(' '); - } - if (Array.isArray(scope)) { - return scope.filter((s): s is string => typeof s === 'string'); + this.logger.error({ err }, 'jwt verification failed'); + return { authenticated: false, reason: 'jwt verification failed' }; } - return undefined; } } diff --git a/packages/auth/src/oauth2.test.ts b/packages/auth/src/oauth2.test.ts new file mode 100644 index 0000000..15d9d99 --- /dev/null +++ b/packages/auth/src/oauth2.test.ts @@ -0,0 +1,614 @@ +import { createServer } from 'node:http'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { A2AError } from '@reaatech/a2a-reference-core'; +import { SignJWT, exportJWK, exportSPKI, generateKeyPair } from 'jose'; +import { describe, expect, it } from 'vitest'; +import { OAuth2Strategy } from './oauth2.js'; + +function startTokenServer( + handler: (req: IncomingMessage, res: ServerResponse) => void, +): Promise<{ url: string; close: () => void }> { + return new Promise((resolve) => { + const server = createServer(handler); + server.listen(0, () => { + const address = server.address(); + const port = address && typeof address === 'object' ? address.port : 0; + resolve({ url: `http://localhost:${port}/token`, close: () => server.close() }); + }); + }); +} + +describe('OAuth2Strategy', () => { + describe('authenticate', () => { + it('rejects missing authorization header', async () => { + const strategy = new OAuth2Strategy({ + issuer: 'https://issuer.example', + tokenEndpoint: 'https://example.com/token', + clientId: 'client-1', + clientSecret: 'secret', + publicKey: 'dummy', + }); + const result = await strategy.authenticate({ headers: {} }); + expect(result.authenticated).toBe(false); + expect(result.reason).toBe('missing token'); + }); + + it('rejects non-bearer authorization header', async () => { + const strategy = new OAuth2Strategy({ + issuer: 'https://issuer.example', + tokenEndpoint: 'https://example.com/token', + clientId: 'client-1', + clientSecret: 'secret', + publicKey: 'dummy', + }); + const result = await strategy.authenticate({ + headers: { authorization: 'Basic dXNlcjpwYXNz' }, + }); + expect(result.authenticated).toBe(false); + expect(result.reason).toBe('invalid authorization header format'); + }); + + it('rejects token when no verification key is configured', async () => { + const strategy = new OAuth2Strategy({ + issuer: 'https://issuer.example', + tokenEndpoint: 'https://example.com/token', + clientId: 'client-1', + clientSecret: 'secret', + }); + const result = await strategy.authenticate({ + headers: { authorization: 'Bearer some-token' }, + }); + expect(result.authenticated).toBe(false); + expect(result.reason).toBe('no verification key configured'); + }); + + it('authenticates a valid JWT with publicKey', async () => { + const { privateKey, publicKey } = await generateKeyPair('RS256'); + const spki = await exportSPKI(publicKey); + + const token = await new SignJWT({ sub: 'user-123', scope: 'read write' }) + .setProtectedHeader({ alg: 'RS256' }) + .setIssuer('https://issuer.example') + .setAudience('https://api.example') + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey); + + const strategy = new OAuth2Strategy({ + issuer: 'https://issuer.example', + audience: 'https://api.example', + tokenEndpoint: 'https://example.com/token', + clientId: 'client-1', + clientSecret: 'secret', + publicKey: spki, + }); + const result = await strategy.authenticate({ + headers: { authorization: `Bearer ${token}` }, + }); + + expect(result.authenticated).toBe(true); + expect(result.principal).toBe('user-123'); + expect(result.scopes).toEqual(['read', 'write']); + }); + + it('authenticates a valid JWT via jwksUri', async () => { + const { privateKey, publicKey } = await generateKeyPair('RS256'); + const jwk = await exportJWK(publicKey); + jwk.kid = 'test-key-1'; + jwk.alg = 'RS256'; + jwk.use = 'sig'; + const jwks = { keys: [jwk] }; + + const server = createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(jwks)); + }); + await new Promise((resolve) => server.listen(0, resolve)); + const address = server.address(); + const port = typeof address === 'string' ? 0 : address?.port; + const jwksUri = `http://localhost:${port}/.well-known/jwks.json`; + + const token = await new SignJWT({ sub: 'user-jwks', scope: 'read' }) + .setProtectedHeader({ alg: 'RS256', kid: 'test-key-1' }) + .setIssuer('https://issuer.example') + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey); + + const strategy = new OAuth2Strategy({ + issuer: 'https://issuer.example', + tokenEndpoint: 'https://example.com/token', + clientId: 'client-1', + clientSecret: 'secret', + jwksUri, + }); + const result = await strategy.authenticate({ + headers: { authorization: `Bearer ${token}` }, + }); + + expect(result.authenticated).toBe(true); + expect(result.principal).toBe('user-jwks'); + expect(result.scopes).toEqual(['read']); + + server.close(); + }); + + it('rejects an expired JWT', async () => { + const { privateKey, publicKey } = await generateKeyPair('RS256'); + const spki = await exportSPKI(publicKey); + + const token = await new SignJWT({ sub: 'user-123' }) + .setProtectedHeader({ alg: 'RS256' }) + .setIssuer('https://issuer.example') + .setIssuedAt() + .setExpirationTime('-1h') + .sign(privateKey); + + const strategy = new OAuth2Strategy({ + issuer: 'https://issuer.example', + tokenEndpoint: 'https://example.com/token', + clientId: 'client-1', + clientSecret: 'secret', + publicKey: spki, + }); + const result = await strategy.authenticate({ + headers: { authorization: `Bearer ${token}` }, + }); + + expect(result.authenticated).toBe(false); + expect(result.reason).toMatch(/oauth2 verification failed/); + }); + + it('rejects a malformed JWT', async () => { + const strategy = new OAuth2Strategy({ + issuer: 'https://issuer.example', + tokenEndpoint: 'https://example.com/token', + clientId: 'client-1', + clientSecret: 'secret', + publicKey: 'dummy', + }); + const result = await strategy.authenticate({ + headers: { authorization: 'Bearer not-a-jwt' }, + }); + + expect(result.authenticated).toBe(false); + expect(result.reason).toMatch(/oauth2 verification failed/); + }); + + it('handles array authorization header', async () => { + const { privateKey, publicKey } = await generateKeyPair('RS256'); + const spki = await exportSPKI(publicKey); + + const token = await new SignJWT({ sub: 'user-123' }) + .setProtectedHeader({ alg: 'RS256' }) + .setIssuer('https://issuer.example') + .setIssuedAt() + .setExpirationTime('1h') + .sign(privateKey); + + const strategy = new OAuth2Strategy({ + issuer: 'https://issuer.example', + tokenEndpoint: 'https://example.com/token', + clientId: 'client-1', + clientSecret: 'secret', + publicKey: spki, + }); + const result = await strategy.authenticate({ + headers: { authorization: [`Bearer ${token}`] }, + }); + + expect(result.authenticated).toBe(true); + expect(result.principal).toBe('user-123'); + }); + }); + + describe('exchangeClientCredentials', () => { + it('exchanges client credentials for a token', async () => { + const { url, close } = await startTokenServer((req, res) => { + expect(req.method).toBe('POST'); + expect(req.headers['content-type']).toBe('application/x-www-form-urlencoded'); + + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + const params = new URLSearchParams(body); + expect(params.get('grant_type')).toBe('client_credentials'); + expect(params.get('client_id')).toBe('client-1'); + expect(params.get('client_secret')).toBe('secret'); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ access_token: 'access-123', token_type: 'Bearer', expires_in: 3600 }), + ); + }); + }); + + const strategy = new OAuth2Strategy({ + issuer: 'https://issuer.example', + tokenEndpoint: url, + clientId: 'client-1', + clientSecret: 'secret', + }); + const result = await strategy.exchangeClientCredentials(); + + expect(result.accessToken).toBe('access-123'); + expect(result.expiresIn).toBe(3600); + + close(); + }); + + it('passes scopes and audience when provided', async () => { + const { url, close } = await startTokenServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + const params = new URLSearchParams(body); + expect(params.get('scope')).toBe('read write'); + expect(params.get('audience')).toBe('https://api.example'); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ access_token: 'access-456', token_type: 'Bearer', expires_in: 7200 }), + ); + }); + }); + + const strategy = new OAuth2Strategy({ + issuer: 'https://issuer.example', + tokenEndpoint: url, + clientId: 'client-1', + clientSecret: 'secret', + audience: 'https://api.example', + }); + const result = await strategy.exchangeClientCredentials(['read', 'write']); + + expect(result.accessToken).toBe('access-456'); + expect(result.expiresIn).toBe(7200); + + close(); + }); + + it('uses default scopes from options when none provided', async () => { + const { url, close } = await startTokenServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + const params = new URLSearchParams(body); + expect(params.get('scope')).toBe('default-scope'); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ access_token: 'access-789', token_type: 'Bearer' })); + }); + }); + + const strategy = new OAuth2Strategy({ + issuer: 'https://issuer.example', + tokenEndpoint: url, + clientId: 'client-1', + clientSecret: 'secret', + scopes: ['default-scope'], + }); + const result = await strategy.exchangeClientCredentials(); + + expect(result.accessToken).toBe('access-789'); + + close(); + }); + + it('rejects non-Bearer token type in response', async () => { + const { url, close } = await startTokenServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ access_token: 'access-tok', token_type: 'Mac' })); + }); + + const strategy = new OAuth2Strategy({ + issuer: 'https://issuer.example', + tokenEndpoint: url, + clientId: 'client-1', + clientSecret: 'secret', + }); + await expect(strategy.exchangeClientCredentials()).rejects.toThrow('Unexpected token type'); + + close(); + }); + + it('rejects missing access_token in response', async () => { + const { url, close } = await startTokenServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ token_type: 'Bearer' })); + }); + + const strategy = new OAuth2Strategy({ + issuer: 'https://issuer.example', + tokenEndpoint: url, + clientId: 'client-1', + clientSecret: 'secret', + }); + await expect(strategy.exchangeClientCredentials()).rejects.toThrow(A2AError); + + close(); + }); + }); + + describe('exchangeAuthorizationCode', () => { + it('exchanges authorization code for a token', async () => { + const { url, close } = await startTokenServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + const params = new URLSearchParams(body); + expect(params.get('grant_type')).toBe('authorization_code'); + expect(params.get('code')).toBe('auth-code-123'); + expect(params.get('redirect_uri')).toBe('https://example.com/callback'); + expect(params.get('client_id')).toBe('client-1'); + expect(params.get('client_secret')).toBe('secret'); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + access_token: 'access-code-123', + token_type: 'Bearer', + refresh_token: 'refresh-123', + expires_in: 3600, + }), + ); + }); + }); + + const strategy = new OAuth2Strategy({ + issuer: 'https://issuer.example', + tokenEndpoint: url, + clientId: 'client-1', + clientSecret: 'secret', + }); + const result = await strategy.exchangeAuthorizationCode( + 'auth-code-123', + 'https://example.com/callback', + ); + + expect(result.accessToken).toBe('access-code-123'); + expect(result.refreshToken).toBe('refresh-123'); + expect(result.expiresIn).toBe(3600); + + close(); + }); + + it('includes code_verifier when provided', async () => { + const { url, close } = await startTokenServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + const params = new URLSearchParams(body); + expect(params.get('code_verifier')).toBe('verifier-value'); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ access_token: 'access-code-456', token_type: 'Bearer' })); + }); + }); + + const strategy = new OAuth2Strategy({ + issuer: 'https://issuer.example', + tokenEndpoint: url, + clientId: 'client-1', + clientSecret: 'secret', + }); + const result = await strategy.exchangeAuthorizationCode( + 'auth-code-456', + 'https://example.com/callback', + 'verifier-value', + ); + + expect(result.accessToken).toBe('access-code-456'); + + close(); + }); + }); + + describe('refreshAccessToken', () => { + it('refreshes access token using a refresh token', async () => { + const { url, close } = await startTokenServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + const params = new URLSearchParams(body); + expect(params.get('grant_type')).toBe('refresh_token'); + expect(params.get('refresh_token')).toBe('old-refresh-token'); + expect(params.get('client_id')).toBe('client-1'); + expect(params.get('client_secret')).toBe('secret'); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + access_token: 'new-access-token', + token_type: 'Bearer', + refresh_token: 'new-refresh-token', + expires_in: 3600, + }), + ); + }); + }); + + const strategy = new OAuth2Strategy({ + issuer: 'https://issuer.example', + tokenEndpoint: url, + clientId: 'client-1', + clientSecret: 'secret', + }); + const result = await strategy.refreshAccessToken('old-refresh-token'); + + expect(result.accessToken).toBe('new-access-token'); + expect(result.refreshToken).toBe('new-refresh-token'); + expect(result.expiresIn).toBe(3600); + + close(); + }); + + it('falls back to original refresh token when response omits it', async () => { + const { url, close } = await startTokenServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ access_token: 'new-access', token_type: 'Bearer' })); + }); + + const strategy = new OAuth2Strategy({ + issuer: 'https://issuer.example', + tokenEndpoint: url, + clientId: 'client-1', + clientSecret: 'secret', + }); + const result = await strategy.refreshAccessToken('fallback-refresh'); + + expect(result.accessToken).toBe('new-access'); + expect(result.refreshToken).toBe('fallback-refresh'); + + close(); + }); + + it('passes scopes when provided', async () => { + const { url, close } = await startTokenServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + const params = new URLSearchParams(body); + expect(params.get('scope')).toBe('read write'); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ access_token: 'tok', token_type: 'Bearer' })); + }); + }); + + const strategy = new OAuth2Strategy({ + issuer: 'https://issuer.example', + tokenEndpoint: url, + clientId: 'client-1', + clientSecret: 'secret', + }); + await strategy.refreshAccessToken('some-refresh', ['read', 'write']); + + close(); + }); + + it('does not pass scope when scopes array is empty', async () => { + const { url, close } = await startTokenServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + const params = new URLSearchParams(body); + expect(params.get('scope')).toBeNull(); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ access_token: 'tok', token_type: 'Bearer' })); + }); + }); + + const strategy = new OAuth2Strategy({ + issuer: 'https://issuer.example', + tokenEndpoint: url, + clientId: 'client-1', + clientSecret: 'secret', + }); + await strategy.refreshAccessToken('some-refresh', []); + + close(); + }); + }); + + describe('fetch error handling', () => { + it('throws on non-ok token response', async () => { + const { url, close } = await startTokenServer((_req, res) => { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'invalid_grant' })); + }); + + const strategy = new OAuth2Strategy({ + issuer: 'https://issuer.example', + tokenEndpoint: url, + clientId: 'client-1', + clientSecret: 'secret', + }); + await expect(strategy.exchangeClientCredentials()).rejects.toThrow( + 'token request failed with status 400', + ); + + close(); + }); + + it('throws on invalid response JSON', async () => { + const { url, close } = await startTokenServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('not-json'); + }); + + const strategy = new OAuth2Strategy({ + issuer: 'https://issuer.example', + tokenEndpoint: url, + clientId: 'client-1', + clientSecret: 'secret', + }); + await expect(strategy.exchangeClientCredentials()).rejects.toThrow(); + + close(); + }); + }); + + describe('fetch timeout', () => { + it('aborts when token endpoint does not respond within timeout', async () => { + const server = createServer(() => { + // Accept connection but never respond + }); + await new Promise((resolve) => server.listen(0, resolve)); + const address = server.address(); + const port = typeof address === 'string' ? 0 : address?.port; + const url = `http://localhost:${port}/token`; + + const strategy = new OAuth2Strategy({ + issuer: 'https://issuer.example', + tokenEndpoint: url, + clientId: 'client-1', + clientSecret: 'secret', + fetchTimeoutMs: 100, + }); + + await expect(strategy.exchangeClientCredentials()).rejects.toThrow(); + + server.close(); + }); + }); + + describe('toJSON', () => { + it('returns sanitized options with masked clientSecret', () => { + const strategy = new OAuth2Strategy({ + issuer: 'https://issuer.example', + tokenEndpoint: 'https://example.com/token', + clientId: 'client-1', + clientSecret: 'supersecret', + scopes: ['read', 'write'], + fetchTimeoutMs: 5000, + }); + + const json = strategy.toJSON(); + + expect(json.clientSecret).toBe('***'); + expect(json.clientId).toBe('client-1'); + expect(json.issuer).toBe('https://issuer.example'); + expect(json.tokenEndpoint).toBe('https://example.com/token'); + expect(json.scopes).toEqual(['read', 'write']); + expect(json.fetchTimeoutMs).toBe(5000); + }); + }); +}); diff --git a/packages/auth/src/oauth2.ts b/packages/auth/src/oauth2.ts new file mode 100644 index 0000000..d0b1f12 --- /dev/null +++ b/packages/auth/src/oauth2.ts @@ -0,0 +1,246 @@ +import { A2AError } from '@reaatech/a2a-reference-core'; +import { type Logger, defaultLogger } from '@reaatech/a2a-reference-observability'; +import { createRemoteJWKSet, importSPKI, jwtVerify } from 'jose'; +import type { JWTPayload } from 'jose'; +import { z } from 'zod'; +import type { AuthContext, AuthResult, AuthStrategy } from './strategy.js'; +import { extractScopes } from './utils.js'; + +export interface OAuth2StrategyOptions { + issuer: string; + tokenEndpoint: string; + authorizationEndpoint?: string; + clientId: string; + /** Sensitive — avoid logging or serializing this value. */ + clientSecret: string; + scopes?: string[]; + audience?: string; + jwksUri?: string; + publicKey?: string; + algorithm?: string; + fetchTimeoutMs?: number; + logger?: Logger; +} + +const DEFAULT_FETCH_TIMEOUT_MS = 10_000; + +const ZodTokenResponse = z.object({ + access_token: z.string().min(1), + token_type: z.string().min(1), + expires_in: z.number().optional(), + refresh_token: z.string().optional(), + scope: z.string().optional(), +}); + +interface TokenResponse { + access_token: string; + expires_in?: number; + refresh_token?: string; + token_type: string; +} + +function parseTokenResponse(data: unknown): TokenResponse { + const result = ZodTokenResponse.safeParse(data); + if (!result.success) { + throw new A2AError( + 'InvalidTokenResponse', + `OAuth2 token response validation failed: ${result.error.message}`, + ); + } + const { access_token, token_type, expires_in, refresh_token } = result.data; + // RFC 6749 §7.1 defines token_type as case-insensitive. + if (token_type.toLowerCase() !== 'bearer') { + throw new A2AError('InvalidTokenType', `Unexpected token type: ${token_type}`); + } + return { access_token, expires_in, refresh_token, token_type }; +} + +export type OAuth2ClientCredentialsGrantRequest = { + grant_type: 'client_credentials'; + client_id: string; + client_secret: string; + scope?: string; + audience?: string; +}; + +export type OAuth2AuthorizationCodeGrantRequest = { + grant_type: 'authorization_code'; + code: string; + redirect_uri: string; + client_id: string; + client_secret: string; + code_verifier?: string; +}; + +export class OAuth2Strategy implements AuthStrategy { + private jwksCache: ReturnType | null = null; + private fetchTimeoutMs: number; + private logger: Logger; + + constructor(private options: OAuth2StrategyOptions) { + this.fetchTimeoutMs = options.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS; + this.logger = options.logger ?? defaultLogger; + if (options.jwksUri) { + try { + this.jwksCache = createRemoteJWKSet(new URL(options.jwksUri)); + } catch (err) { + throw new A2AError( + 'InvalidJwksUri', + `Invalid jwksUri: ${options.jwksUri} — must be a valid URL`, + ); + } + } + } + + toJSON(): Omit & { clientSecret: '***' } { + const { clientSecret: _, ...rest } = this.options; + return { ...rest, clientSecret: '***' }; + } + + async authenticate(context: AuthContext): Promise { + const authHeader = context.headers.authorization; + const headerValue = Array.isArray(authHeader) ? authHeader[0] : authHeader; + + if (!headerValue) { + return { authenticated: false, reason: 'missing token' }; + } + + if (!headerValue.toLowerCase().startsWith('bearer ')) { + return { authenticated: false, reason: 'invalid authorization header format' }; + } + + const token = headerValue.slice(7).trim(); + + if (!this.options.jwksUri && !this.options.publicKey) { + return { authenticated: false, reason: 'no verification key configured' }; + } + + try { + let jwtPayload: JWTPayload; + + if (this.options.publicKey) { + const key = await importSPKI(this.options.publicKey, this.options.algorithm ?? 'RS256'); + const result = await jwtVerify(token, key, { + issuer: this.options.issuer, + audience: this.options.audience, + }); + jwtPayload = result.payload; + } else if (this.jwksCache) { + const result = await jwtVerify(token, this.jwksCache, { + issuer: this.options.issuer, + audience: this.options.audience, + }); + jwtPayload = result.payload; + } else { + return { authenticated: false, reason: 'no verification key configured' }; + } + + if (typeof jwtPayload.sub !== 'string' || !jwtPayload.sub.trim()) { + return { authenticated: false, reason: 'missing or invalid sub claim' }; + } + const sub = jwtPayload.sub; + const scopes = extractScopes(jwtPayload as Record); + + return { authenticated: true, principal: sub, scopes }; + } catch (err) { + this.logger.error({ err }, 'oauth2 verification failed'); + return { authenticated: false, reason: 'oauth2 verification failed' }; + } + } + + async exchangeClientCredentials( + scopes?: string[], + ): Promise<{ accessToken: string; expiresIn?: number }> { + const params = new URLSearchParams(); + params.set('grant_type', 'client_credentials'); + params.set('client_id', this.options.clientId); + params.set('client_secret', this.options.clientSecret); + const scope = scopes !== undefined ? scopes.join(' ') : this.options.scopes?.join(' '); + if (scope) params.set('scope', scope); + if (this.options.audience) params.set('audience', this.options.audience); + + const data = await this.fetchToken(params); + return { accessToken: data.access_token, expiresIn: data.expires_in }; + } + + async exchangeAuthorizationCode( + code: string, + redirectUri: string, + codeVerifier?: string, + ): Promise<{ accessToken: string; refreshToken?: string; expiresIn?: number }> { + const body: Record = { + grant_type: 'authorization_code', + code, + redirect_uri: redirectUri, + client_id: this.options.clientId, + client_secret: this.options.clientSecret, + }; + + if (codeVerifier) { + body.code_verifier = codeVerifier; + } + + const data = await this.fetchToken(new URLSearchParams(body)); + return { + accessToken: data.access_token, + refreshToken: data.refresh_token, + expiresIn: data.expires_in, + }; + } + + async refreshAccessToken( + refreshToken: string, + scopes?: string[], + ): Promise<{ accessToken: string; refreshToken?: string; expiresIn?: number }> { + const body: Record = { + grant_type: 'refresh_token', + client_id: this.options.clientId, + client_secret: this.options.clientSecret, + refresh_token: refreshToken, + }; + + if (scopes?.length) { + body.scope = scopes.join(' '); + } + + const data = await this.fetchToken(new URLSearchParams(body)); + return { + accessToken: data.access_token, + refreshToken: data.refresh_token ?? refreshToken, + expiresIn: data.expires_in, + }; + } + + private async fetchToken(body: URLSearchParams): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.fetchTimeoutMs); + + try { + const response = await fetch(this.options.tokenEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + signal: controller.signal, + }); + + if (!response.ok) { + const errorBody = await response.text().catch(() => ''); + this.logger.error({ body: errorBody }, 'oauth2 token request failed'); + throw new A2AError( + 'TokenRequestFailed', + `token request failed with status ${response.status}`, + ); + } + + let raw: unknown; + try { + raw = await response.json(); + } catch { + throw new A2AError('InvalidTokenResponse', 'failed to parse token response body'); + } + return parseTokenResponse(raw); + } finally { + clearTimeout(timeoutId); + } + } +} diff --git a/packages/auth/src/utils.test.ts b/packages/auth/src/utils.test.ts new file mode 100644 index 0000000..5399327 --- /dev/null +++ b/packages/auth/src/utils.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { extractScopes } from './utils.js'; + +describe('extractScopes', () => { + it('splits string scope on spaces', () => { + const result = extractScopes({ scope: 'read write admin' }); + expect(result).toEqual(['read', 'write', 'admin']); + }); + + it('returns array scope as-is after filtering non-strings', () => { + const result = extractScopes({ scope: ['read', 'write'] }); + expect(result).toEqual(['read', 'write']); + }); + + it('returns undefined for number scope', () => { + const result = extractScopes({ scope: 42 }); + expect(result).toBeUndefined(); + }); + + it('returns undefined for missing scope', () => { + const result = extractScopes({}); + expect(result).toBeUndefined(); + }); + + it('returns empty array for empty string scope', () => { + const result = extractScopes({ scope: '' }); + expect(result).toEqual([]); + }); + + it('filters out non-strings from mixed-type array', () => { + const result = extractScopes({ scope: ['read', 42, 'write', true, {}] }); + expect(result).toEqual(['read', 'write']); + }); + + it('returns undefined for object scope', () => { + const result = extractScopes({ scope: { read: true } }); + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/auth/src/utils.ts b/packages/auth/src/utils.ts new file mode 100644 index 0000000..5991070 --- /dev/null +++ b/packages/auth/src/utils.ts @@ -0,0 +1,10 @@ +export function extractScopes(payload: Record): string[] | undefined { + const scope = payload.scope; + if (typeof scope === 'string') { + return scope.length === 0 ? [] : scope.split(' '); + } + if (Array.isArray(scope)) { + return scope.filter((s): s is string => typeof s === 'string'); + } + return undefined; +} diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..7420161 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,47 @@ +{ + "name": "@reaatech/a2a-reference-cli", + "version": "0.1.0", + "description": "CLI scaffolding tool for A2A agents", + "license": "MIT", + "author": "Rick Somers (https://reaatech.com)", + "repository": { + "type": "git", + "url": "https://github.com/reaatech/a2a-reference-ts.git", + "directory": "packages/cli" + }, + "homepage": "https://github.com/reaatech/a2a-reference-ts/tree/main/packages/cli#readme", + "bugs": { + "url": "https://github.com/reaatech/a2a-reference-ts/issues" + }, + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "bin": { + "a2a": "./dist/index.js" + }, + "files": ["dist"], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsup", + "dev": "tsx src/index.ts", + "clean": "rm -rf dist", + "test": "vitest run --passWithNoTests", + "test:coverage": "vitest run --coverage --passWithNoTests" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "tsup": "^8.4.0", + "typescript": "^5.8.3", + "vitest": "^3.1.1" + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 0000000..4f118fb --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,273 @@ +#!/usr/bin/env node + +import { mkdir, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +interface ScaffoldOptions { + name: string; + framework: 'express' | 'hono'; + auth: 'none' | 'api-key' | 'jwt'; + mcp: boolean; + dir: string; +} + +async function scaffold(options: ScaffoldOptions): Promise { + const { name, framework, auth, mcp, dir } = options; + const targetDir = resolve(dir); + + await mkdir(targetDir, { recursive: true }); + await mkdir(join(targetDir, 'src'), { recursive: true }); + + // package.json + await writeFile( + join(targetDir, 'package.json'), + JSON.stringify( + { + name, + version: '0.1.0', + private: true, + type: 'module', + scripts: { + dev: 'tsx watch src/index.ts', + build: 'tsc', + start: 'node dist/index.js', + }, + dependencies: { + '@reaatech/a2a-reference-core': '^0.1.0', + '@reaatech/a2a-reference-server': '^0.1.0', + ...(auth !== 'none' ? { '@reaatech/a2a-reference-auth': '^0.1.0' } : {}), + ...(mcp ? { '@reaatech/a2a-reference-mcp-bridge': '^0.1.0' } : {}), + ...(framework === 'hono' ? { '@hono/node-server': '^1.0.0' } : {}), + }, + devDependencies: { + typescript: '^5.8.0', + tsx: '^4.19.0', + '@types/node': '^25.0.0', + }, + }, + null, + 2, + ), + ); + + // tsconfig.json + await writeFile( + join(targetDir, 'tsconfig.json'), + JSON.stringify( + { + compilerOptions: { + target: 'ES2022', + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + esModuleInterop: true, + outDir: 'dist', + rootDir: 'src', + declaration: true, + }, + include: ['src/**/*'], + }, + null, + 2, + ), + ); + + // Agent implementation + const agentCode = generateAgentCode(name, framework, auth, mcp); + await writeFile(join(targetDir, 'src', 'agent.ts'), agentCode); + + // Server entry + const serverCode = generateServerCode(name, framework, auth); + await writeFile(join(targetDir, 'src', 'index.ts'), serverCode); + + // .gitignore + await writeFile(join(targetDir, '.gitignore'), 'node_modules\ndist\n.env\n'); + + process.stdout.write(` +✅ Scaffolded A2A agent "${name}" at ${targetDir} + +Next steps: + cd ${name} + npm install + npm run dev + +Your agent will be available at http://localhost:3001 +Agent Card: http://localhost:3001/.well-known/agent.json +`); +} + +function generateAgentCode( + _name: string, + _framework: 'express' | 'hono', + auth: 'none' | 'api-key' | 'jwt', + mcp: boolean, +): string { + const authImport = + auth !== 'none' + ? `import { ${auth === 'api-key' ? 'ApiKeyStrategy' : 'JwtStrategy'} } from '@reaatech/a2a-reference-auth';\n` + : ''; + const authConst = + auth === 'api-key' + ? "// Set A2A_API_KEY env var or replace this with your key\nconst authConfig = new ApiKeyStrategy({ keys: new Set([process.env.A2A_API_KEY || ''].filter(Boolean)) });\n" + : auth === 'jwt' + ? "const authConfig = new JwtStrategy({ issuer: 'https://issuer.example.com', jwksUri: 'https://issuer.example.com/.well-known/jwks.json' });\n" + : ''; + + return `import type { AgentExecutor, ExecutionEventBus } from '@reaatech/a2a-reference-server'; +import type { Task, Message } from '@reaatech/a2a-reference-core'; +${authImport} +${mcp ? `import { McpToolAdapter } from '@reaatech/a2a-reference-mcp-bridge';` : ''} + +${authConst} +export const agentCard: import('@reaatech/a2a-reference-core').AgentCard = { + name: '${_name}', + description: 'An A2A agent scaffolded with a2a-cli', + url: 'http://localhost:3001', + version: '1.0.0', + protocolVersion: '0.3.0', + capabilities: { streaming: true }, + defaultInputModes: ['text'], + defaultOutputModes: ['text'], + skills: [ + { + id: 'echo', + name: 'Echo', + description: 'Echoes back the input text', + tags: ['utility'], + examples: ['Say hello'], + inputModes: ['text'], + outputModes: ['text'], + }, + ], + supportedInterfaces: [], +}; + +export const executor: AgentExecutor = { + execute: async (context: { task: Task; message: Message }, eventBus: ExecutionEventBus) => { + const text = context.message.parts[0] && 'text' in context.message.parts[0] + ? context.message.parts[0].text + : 'No text provided'; + + const response = \`\${agentCard.name} received: \${text}\`; + + await eventBus.emitArtifactUpdate({ + kind: 'artifact', + artifact: { + parts: [{ kind: 'text', text: response }], + }, + }); + + await eventBus.emitStatusUpdate({ + kind: 'status', + status: { state: 'completed' }, + final: true, + }); + }, +}; + +${authConst ? 'export { authConfig };\n' : ''} +`; +} + +function generateServerCode( + name: string, + framework: 'express' | 'hono', + auth: 'none' | 'api-key' | 'jwt', +): string { + if (framework === 'express') { + return `import { createA2AExpressApp } from '@reaatech/a2a-reference-server'; +import { agentCard, executor${auth !== 'none' ? ', authConfig' : ''} } from './agent.js'; +import { defaultLogger } from '@reaatech/a2a-reference-observability'; + +const app = createA2AExpressApp({ + agentCard, + executor, + ${auth !== 'none' ? 'authStrategy: authConfig,' : ''} + // rateLimiter: new RateLimiter({ maxRequests: 100 }), + // taskStore: new InMemoryTaskStore(), +}); + +const PORT = process.env.PORT ?? 3001; +const server = app.listen(PORT, () => { + defaultLogger.info(\`${name} running on http://localhost:\${PORT}\`); + defaultLogger.info(\`Agent Card: http://localhost:\${PORT}/.well-known/agent.json\`); +}); + +process.on('SIGTERM', async () => { + await app.shutdown(); + server.close(() => { + process.exit(0); + }); +}); +`; + } + + return `import { createA2AHonoApp } from '@reaatech/a2a-reference-server'; +import { serve } from '@hono/node-server'; +import { agentCard, executor${auth !== 'none' ? ', authConfig' : ''} } from './agent.js'; +import { defaultLogger } from '@reaatech/a2a-reference-observability'; + +const app = createA2AHonoApp({ + agentCard, + executor, + ${auth !== 'none' ? 'authStrategy: authConfig,' : ''} + // rateLimiter: new RateLimiter({ maxRequests: 100 }), +}); + +const PORT = process.env.PORT ?? 3001; + +serve({ fetch: app.fetch, port: PORT }, (info) => { + defaultLogger.info(\`${name} running on http://localhost:\${info.port}\`); + defaultLogger.info(\`Agent Card: http://localhost:\${info.port}/.well-known/agent.json\`); +}); +`; +} + +async function main() { + const args = process.argv.slice(2); + const command = args[0]; + + if (command === 'init' || command === 'new') { + const flags = new Set(['--hono', '--jwt', '--api-key', '--mcp']); + const positional = args.slice(1).filter((a) => !flags.has(a)); + const rawName = positional[0] ?? 'my-a2a-agent'; + const name = rawName.replace(/[^a-zA-Z0-9_-]/g, '_'); + const framework = args.includes('--hono') ? 'hono' : 'express'; + const auth = args.includes('--jwt') ? 'jwt' : args.includes('--api-key') ? 'api-key' : 'none'; + const mcp = args.includes('--mcp'); + + if (rawName !== name) { + process.stderr.write(`Warning: Name sanitized from "${rawName}" to "${name}"\n`); + } + + await scaffold({ name, framework, auth, mcp, dir: name }); + } else if (command === 'help' || command === '--help' || command === '-h') { + process.stdout.write(` +a2a - A2A Agent Scaffolding CLI + +Usage: + a2a init [options] + +Options: + --hono Use Hono instead of Express (default: Express) + --api-key Add API key authentication + --jwt Add JWT authentication + --mcp Add MCP bridge integration + -h, --help Show this help + +Examples: + a2a init my-agent + a2a init my-agent --hono + a2a init my-agent --jwt --mcp + a2a init my-agent --api-key +`); + } else { + process.stdout.write('Usage: a2a init [options]\n'); + process.stdout.write('Run a2a --help for more information\n'); + } +} + +main().catch((err) => { + process.stderr.write(`Error: ${err instanceof Error ? err.message : err}\n`); + process.exit(1); +}); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..90d76d7 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/cli/tsup.config.ts b/packages/cli/tsup.config.ts new file mode 100644 index 0000000..23260bb --- /dev/null +++ b/packages/cli/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + dts: true, + clean: true, + banner: { js: '#!/usr/bin/env node' }, +}); diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 0000000..e30b907 --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: false, + environment: 'node', + coverage: { + reporter: ['text', 'json-summary'], + }, + }, +}); diff --git a/packages/client/README.md b/packages/client/README.md index 3a09951..e74f9ec 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -1,221 +1,72 @@ # @reaatech/a2a-reference-client -[![npm version](https://img.shields.io/npm/v/@reaatech/a2a-reference-client.svg)](https://www.npmjs.com/package/@reaatech/a2a-reference-client) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/reaatech/a2a-reference-ts/blob/main/LICENSE) -[![CI](https://img.shields.io/github/actions/workflow/status/reaatech/a2a-reference-ts/ci.yml?branch=main&label=CI)](https://github.com/reaatech/a2a-reference-ts/actions/workflows/ci.yml) +Type-safe A2A client SDK for task submission, discovery, and SSE streaming. -> **Status:** Pre-1.0 — APIs may change in minor versions. Pin to a specific version in production. +## Usage -TypeScript client SDK for discovering A2A agents, submitting tasks, and consuming real-time Server-Sent Events (SSE) streams. Built on the standard `fetch` API with pluggable transport for edge runtimes and custom headers. +```ts +import { A2AClient } from '@reaatech/a2a-reference-client'; -## Installation - -```bash -npm install @reaatech/a2a-reference-client -# or -pnpm add @reaatech/a2a-reference-client +const client = new A2AClient({ baseUrl: 'http://localhost:3000' }); ``` -## Feature Overview - -- **Agent discovery** — fetch and cache agent cards from `/.well-known/agent.json` -- **JSON-RPC 2.0 transport** — all task operations use standards-compliant RPC -- **SSE streaming** — `AsyncGenerator`-based consumption of status and artifact events -- **Automatic retry** — exponential backoff with jitter on 5xx errors and network failures -- **Pluggable fetch** — inject custom `fetchImpl` for edge runtimes, proxies, or auth headers -- **Full type safety** — all responses validated against Zod schemas from `@reaatech/a2a-reference-core` - -## Quick Start - -```typescript -import { A2AClient } from "@reaatech/a2a-reference-client"; +## Methods -// Discover an agent from its card URL -const client = await A2AClient.discover("http://localhost:3000"); - -// Or construct directly -const client = new A2AClient({ baseUrl: "http://localhost:3000" }); +### Discovery +```ts +const card = await client.getAgentCard(); +// Or discover from a DID +const card = await A2AClient.fromCardUrl('http://example.com/.well-known/agent.json'); +``` -// Send a message and get the resulting task +### Task Management +```ts +// Create a task (sync) const task = await client.sendMessage({ - messageId: "msg-1", - role: "user", - parts: [{ kind: "text", text: "Hello, agent!" }], + messageId: crypto.randomUUID(), + role: 'user', + parts: [{ kind: 'text', text: 'Hello!' }], }); -console.log(`Task ${task.id} is ${task.status.state}`); -``` - -## API Reference - -### `A2AClient` (class) - -#### Static Methods - -| Method | Description | -|--------|-------------| -| `discover(cardUrl, fetchImpl?)` | Fetches agent card from `cardUrl`, validates it, returns a configured `A2AClient` | -| `fromCardUrl(cardUrl, fetchImpl?)` | Alias for `discover` | - -#### Constructor - -```typescript -new A2AClient(options: A2AClientOptions) -``` - -### `A2AClientOptions` - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `baseUrl` | `string` | (required) | Base URL of the A2A agent server | -| `fetchImpl` | `typeof fetch` | `globalThis.fetch` | Custom fetch implementation | -| `agentCardTtlMs` | `number` | `300000` (5 min) | Cache TTL for agent card | -| `maxRetries` | `number` | `0` | Max retry attempts for failed RPC calls | -| `retryDelayMs` | `number` | `1000` | Base delay for exponential backoff | - -### Instance Methods — Discovery - -| Method | Description | -|--------|-------------| -| `getAgentCard()` | Returns the cached agent card (fetches on first call) | -| `clearAgentCardCache()` | Invalidates the cached agent card | - -### Instance Methods — Task Management - -All methods use JSON-RPC 2.0 over `POST {baseUrl}`. +// Get task status +const task = await client.getTask('task-123'); -| Method | RPC Method | Returns | Description | -|--------|------------|---------|-------------| -| `sendMessage(message, contextId?, taskId?)` | `tasks/send` | `Task` | Start or continue a task | -| `getTask(taskId)` | `tasks/get` | `Task` | Retrieve a task by ID | -| `listTasks()` | `tasks/list` | `{ tasks, nextPageToken?, totalSize? }` | List all tasks (paginated) | -| `cancelTask(taskId)` | `tasks/cancel` | `Task` | Request cancellation | +// List tasks +const result = await client.listTasks({ pageSize: 10 }); -### Instance Methods — SSE Streaming - -Both methods check that the agent supports streaming (`capabilities.streaming`) and return `AsyncGenerator`s. - -| Method | HTTP | Yields | Description | -|--------|------|--------|-------------| -| `sendSubscribe(message, contextId?)` | `POST /tasks/sendSubscribe` | `StreamEvent` | Send message + subscribe to stream | -| `subscribe(taskId)` | `GET /tasks/:taskId/subscribe` | `StreamEvent` | Subscribe to existing task stream | - -### Stream Events - -The `AsyncGenerator` yields a union of four event types, discriminated by `kind`: - -```typescript -type StreamEvent = - | TaskStatusUpdateEvent // { kind: "status", taskId?, status, final? } - | TaskArtifactUpdateEvent // { kind: "artifact", taskId?, artifact, append?, lastChunk? } - | { kind: "task", task: Task } - | { kind: "message", message: Message }; +// Cancel a task +const task = await client.cancelTask('task-123'); ``` -## Usage Patterns - -### Streaming Task Progress - -```typescript -const client = new A2AClient({ baseUrl: "http://localhost:3000" }); - +### SSE Streaming +```ts +// Create and stream for await (const event of client.sendSubscribe({ - messageId: "stream-1", - role: "user", - parts: [{ kind: "text", text: "Count to 10" }], + messageId: crypto.randomUUID(), + role: 'user', + parts: [{ kind: 'text', text: 'Stream this' }], })) { - if (event.kind === "status") { - console.log(`Status: ${event.status.state}`); - } else if (event.kind === "artifact") { - const text = event.artifact.parts - .filter((p) => p.kind === "text") - .map((p) => p.text) - .join(" "); - console.log(`Output: ${text}`); + if (event.kind === 'artifact') { + console.log('Artifact:', event.artifact.parts); + } + if (event.kind === 'status' && event.final) { + console.log('Done:', event.status.state); } } -``` - -### Custom Fetch (Auth Headers, Edge Runtimes) - -```typescript -import { A2AClient } from "@reaatech/a2a-reference-client"; - -const client = new A2AClient({ - baseUrl: "http://localhost:3000", - fetchImpl: (url, init) => - globalThis.fetch(url, { - ...init, - headers: { - ...(init?.headers ?? {}), - "x-api-key": process.env.A2A_API_KEY, - }, - }), -}); -const card = await client.getAgentCard(); +// Subscribe to existing task +for await (const event of client.subscribe('task-123')) { + // handle events +} ``` -### Retry with Backoff +## Options -```typescript +```ts const client = new A2AClient({ - baseUrl: "http://localhost:3000", - maxRetries: 3, - retryDelayMs: 500, -}); - -// RPC calls automatically retry on 5xx and network errors -const task = await client.sendMessage({ - messageId: "retry-1", - role: "user", - parts: [{ kind: "text", text: "do work" }], -}); -``` - -### Multi-Agent Orchestration - -```typescript -const mathClient = new A2AClient({ baseUrl: "http://localhost:3001" }); -const formatterClient = new A2AClient({ baseUrl: "http://localhost:3002" }); - -// Delegate to math agent -const mathTask = await mathClient.sendMessage({ - messageId: "math-1", - role: "user", - parts: [{ kind: "text", text: "2 + 2" }], + baseUrl: 'http://localhost:3000', + agentCardTtlMs: 300_000, // Cache agent card for 5 minutes + maxRetries: 3, // Retry on failure + retryDelayMs: 200, // Base delay for exponential backoff }); - -// Poll for result -let result = mathTask; -while (result.status.state !== "completed" && result.status.state !== "failed") { - await sleep(100); - result = await mathClient.getTask(mathTask.id); -} ``` - -## Error Handling - -The client throws typed errors from `@reaatech/a2a-reference-core`: - -| Error | When | -|-------|------| -| `TaskNotFoundError` | Server returns "not found" for a task ID | -| `UnsupportedOperationError` | Streaming requested but agent doesn't support it | -| `InvalidAgentResponseError` | Server response fails Zod validation | - -Retry behavior: -- **5xx responses**: retried with exponential backoff + jitter -- **4xx responses**: not retried (client error) -- **Network errors** (`ECONNREFUSED`, `fetch failed`): retried -- **Timeout**: 30-second `AbortController` per RPC call - -## Related Packages - -- [`@reaatech/a2a-reference-core`](https://www.npmjs.com/package/@reaatech/a2a-reference-core) — Protocol types and Zod schemas -- [`@reaatech/a2a-reference-server`](https://www.npmjs.com/package/@reaatech/a2a-reference-server) — Server framework -- [`@reaatech/a2a-reference-auth`](https://www.npmjs.com/package/@reaatech/a2a-reference-auth) — Authentication strategies - -## License - -[MIT](https://github.com/reaatech/a2a-reference-ts/blob/main/LICENSE) diff --git a/packages/core/README.md b/packages/core/README.md index d1ea606..e7c274f 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,144 +1,55 @@ # @reaatech/a2a-reference-core -[![npm version](https://img.shields.io/npm/v/@reaatech/a2a-reference-core.svg)](https://www.npmjs.com/package/@reaatech/a2a-reference-core) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/reaatech/a2a-reference-ts/blob/main/LICENSE) -[![CI](https://img.shields.io/github/actions/workflow/status/reaatech/a2a-reference-ts/ci.yml?branch=main&label=CI)](https://github.com/reaatech/a2a-reference-ts/actions/workflows/ci.yml) +Canonical A2A protocol types, Zod schemas, error classes, and signature verification. -> **Status:** Pre-1.0 — APIs may change in minor versions. Pin to a specific version in production. +## Usage -Canonical TypeScript types, Zod schemas, and error classes for the [Agent-to-Agent (A2A) protocol](https://github.com/google/A2A). This package is the single source of truth for all A2A protocol shapes used throughout the `@reaatech/a2a-reference-*` ecosystem. - -## Installation - -```bash -npm install @reaatech/a2a-reference-core -# or -pnpm add @reaatech/a2a-reference-core +```ts +import { + TaskSchema, AgentCardSchema, MessageSchema, PartSchema, + TaskNotFoundError, verifyAgentCardSignatures, + AgentCardSignatureSchema, MutualTlsSecuritySchemeSchema, +} from '@reaatech/a2a-reference-core'; ``` -## Feature Overview - -- **70+ exported types and schemas** — every A2A protocol shape has a corresponding Zod schema for runtime validation -- **35 Zod schemas** — parse and validate agent cards, tasks, messages, artifacts, and stream events at the boundary -- **9 typed error classes** — `TaskNotFoundError`, `UnsupportedOperationError`, `VersionNotSupportedError`, and more -- **Zero runtime dependencies** beyond `zod` — lightweight and tree-shakeable -- **Dual ESM/CJS output** — works with `import` and `require` - -## Quick Start - -```typescript -import { TaskSchema, type TaskState, A2AError } from "@reaatech/a2a-reference-core"; - -// Validate a task at the boundary -const rawTask = JSON.parse(incomingJson); -const task = TaskSchema.parse(rawTask); - -// Check terminal states (TaskState is a string union: "submitted" | "working" | ...) -if (task.status.state === "completed") { - console.log("Task finished:", task.artifacts); -} - -// Throw typed errors -throw new A2AError("CUSTOM_ERROR", "Something went wrong", { extra: "context" }); -``` - -## Exports - -### Content Parts - -The atomic content unit in A2A. All messages and artifacts are composed of parts. +## Included Schemas -| Export | Description | -|--------|-------------| -| `PartSchema` / `Part` | Discriminated union of `TextPart`, `FilePart`, `DataPart` | -| `TextPartSchema` / `TextPart` | `{ kind: "text", text: string, metadata?: Record }` | -| `FilePartSchema` / `FilePart` | `{ kind: "file", file: { name?, mimeType?, bytes?, uri? }, metadata? }` | -| `DataPartSchema` / `DataPart` | `{ kind: "data", data: Record, metadata? }` | +- **Parts** — `TextPart`, `FilePart`, `DataPart` with union `Part` +- **Messages** — `Message` with role, parts, metadata, and reference task IDs +- **Task** — Full task lifecycle with `TaskState` (8 states), artifacts, history +- **Agent Card** — Discovery metadata, capabilities, skills, security schemes +- **Security Schemes** — API key, HTTP Bearer, OAuth2, OpenID Connect, **mutual TLS** +- **Signatures** — `AgentCardSignature` with RSA/ECDSA/Ed25519 verification +- **Requests** — `SendMessage`, `GetTask`, `ListTasks`, `CancelTask`, `SubscribeToTask`, `GetExtendedAgentCard`, `TaskPushNotificationConfig` +- **Events** — `TaskStatusUpdateEvent`, `TaskArtifactUpdateEvent`, `StreamResponse` -### Tasks & Messages +## Error Classes -| Export | Description | -|--------|-------------| -| `TaskSchema` / `Task` | Central entity: `id`, `contextId?`, `status`, `artifacts?`, `history?`, `metadata?` | -| `TaskStatusSchema` / `TaskStatus` | `{ state: TaskState, message?, timestamp? }` | -| `TaskState` / `TaskStateSchema` | Enum: `submitted`, `working`, `input-required`, `completed`, `failed`, `canceled`, `rejected`, `auth-required` | -| `MessageSchema` / `Message` | `{ messageId, role: "user" \| "agent", parts: Part[], contextId?, taskId? }` | -| `ArtifactSchema` / `Artifact` | `{ artifactId?, name?, description?, parts: Part[], metadata? }` | +All errors extend `A2AError` with typed error codes: -### Agent Card +- `TaskNotFoundError`, `TaskNotCancelableError` +- `PushNotificationNotSupportedError`, `UnsupportedOperationError` +- `ContentTypeNotSupportedError`, `InvalidAgentResponseError` +- `ExtendedAgentCardNotConfiguredError`, `ExtensionSupportRequiredError` +- `VersionNotSupportedError` -| Export | Description | -|--------|-------------| -| `AgentCardSchema` / `AgentCard` | Full agent descriptor: name, description, url, capabilities, skills, security, interfaces | -| `SkillSchema` / `Skill` | A declared skill: `id`, `name`, `description`, `tags`, `parameters?` | -| `CapabilitySchema` / `Capability` | `{ streaming?, pushNotifications?, stateTransitionHistory? }` | -| `AgentInterfaceSchema` / `AgentInterface` | `{ url, protocolBinding, protocolVersion }` | +## Signature Verification -### Security Schemes +Signatures are JSON Web Signatures (JWS, RFC 7515) computed over the RFC 8785 +(JCS) canonicalization of the Agent Card with its `signatures` field excluded. +Provide a trusted verification key, or set `allowRemoteKeys: true` to fetch the +JWKS from the protected header's `jku` URL. -| Export | Description | -|--------|-------------| -| `SecuritySchemeSchema` / `SecurityScheme` | Union of all four scheme types | -| `ApiKeySecurityScheme` / Schema | API key in header, query, or cookie | -| `HttpSecurityScheme` / Schema | HTTP Bearer authentication | -| `OAuth2SecurityScheme` / Schema | OAuth2 with flows and scopes | -| `OpenIdConnectSecurityScheme` / Schema | OpenID Connect with discovery URL | +```ts +import { verifyAgentCardSignatures, type AgentCardSignature } from '@reaatech/a2a-reference-core'; -### Streaming Events - -| Export | Description | -|--------|-------------| -| `StreamResponseSchema` / `StreamResponse` | SSE event union: `task`, `message`, `status`, `artifact` | -| `TaskStatusUpdateEventSchema` / `TaskStatusUpdateEvent` | `{ kind: "status", taskId?, status: TaskStatus, final? }` | -| `TaskArtifactUpdateEventSchema` / `TaskArtifactUpdateEvent` | `{ kind: "artifact", taskId?, artifact: Artifact, append?, lastChunk? }` | - -### A2A API Requests & Responses - -| Export | Description | -|--------|-------------| -| `SendMessageRequest` / `Response` | Send a message to an agent, returns a `Task` | -| `GetTaskRequest` / `Response` | Retrieve a task by ID | -| `ListTasksRequest` / `Response` | Paginated task listing with optional filters | -| `CancelTaskRequest` / `Response` | Cancel an in-progress task | -| `SubscribeToTaskRequest` | Subscribe to task SSE stream | -| `TaskPushNotificationConfig` | Webhook push notification configuration | - -### Error Classes - -All errors extend `A2AError` which includes `code: string`, `message: string`, and optional `details?: unknown`. - -| Class | Code | When | -|-------|------|------| -| `A2AError` | (custom) | Base class for all A2A errors | -| `TaskNotFoundError` | `TaskNotFoundError` | Requested task does not exist | -| `TaskNotCancelableError` | `TaskNotCancelableError` | Task is in a terminal state | -| `PushNotificationNotSupportedError` | `PushNotificationNotSupportedError` | Agent lacks push notification capability | -| `UnsupportedOperationError` | `UnsupportedOperationError` | Operation not implemented by agent | -| `ContentTypeNotSupportedError` | `ContentTypeNotSupportedError` | Content type not accepted | -| `InvalidAgentResponseError` | `InvalidAgentResponseError` | Downstream agent returned invalid response | -| `ExtendedAgentCardNotConfiguredError` | `ExtendedAgentCardNotConfiguredError` | Extended card retrieval not configured | -| `ExtensionSupportRequiredError` | `ExtensionSupportRequiredError` | Required protocol extension not supported | -| `VersionNotSupportedError` | `VersionNotSupportedError` | Unsupported protocol version | - -## Usage Pattern - -Every schema export has a matching type export. Use the schema for runtime validation and the type for compile-time checking: - -```typescript -import { TaskSchema, type Task } from "@reaatech/a2a-reference-core"; - -function handleResponse(raw: unknown): Task { - // Parse at the boundary — throws ZodError on invalid data - return TaskSchema.parse(raw); +const result = await verifyAgentCardSignatures(agentCard, signatures, { + key: trustedPublicKeyPem, // PEM SPKI/X.509 string, CryptoKey, or a JWKS resolver +}); +if (!result.valid) { + console.error('Invalid signatures:', result.errors); } ``` -## Related Packages - -- [`@reaatech/a2a-reference-server`](https://www.npmjs.com/package/@reaatech/a2a-reference-server) — Express and Hono server adapters -- [`@reaatech/a2a-reference-client`](https://www.npmjs.com/package/@reaatech/a2a-reference-client) — Client SDK for agent discovery and task management -- [`@reaatech/a2a-reference-auth`](https://www.npmjs.com/package/@reaatech/a2a-reference-auth) — Authentication strategies - -## License - -[MIT](https://github.com/reaatech/a2a-reference-ts/blob/main/LICENSE) +Supports RS256/384/512, PS256/384/512, ES256/384/512, and EdDSA (Ed25519); +verification runs on `jose` (WebCrypto), so it works in Node.js and edge runtimes. diff --git a/packages/core/package.json b/packages/core/package.json index c5e64a4..34b2945 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -35,9 +35,11 @@ "clean": "rm -rf dist" }, "dependencies": { + "jose": "^6.0.10", "zod": "^3.24.2" }, "devDependencies": { + "@types/node": "^25.6.0", "tsup": "^8.4.0", "typescript": "^5.8.3", "vitest": "^3.1.1" diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 71c3542..4360e7c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -17,6 +17,11 @@ export { HttpSecuritySchemeSchema, OAuth2SecuritySchemeSchema, OpenIdConnectSecuritySchemeSchema, + OAuthFlowsSchema, + AuthorizationCodeOAuthFlowSchema, + ClientCredentialsOAuthFlowSchema, + ImplicitOAuthFlowSchema, + PasswordOAuthFlowSchema, } from './types/agent-card.js'; export type { AgentCard, @@ -28,6 +33,11 @@ export type { HttpSecurityScheme, OAuth2SecurityScheme, OpenIdConnectSecurityScheme, + OAuthFlows, + AuthorizationCodeOAuthFlow, + ClientCredentialsOAuthFlow, + ImplicitOAuthFlow, + PasswordOAuthFlow, } from './types/agent-card.js'; // Task @@ -91,3 +101,18 @@ export { ExtensionSupportRequiredError, VersionNotSupportedError, } from './types/errors.js'; + +// Signatures +export { + AgentCardSignatureSchema, + MutualTlsSecuritySchemeSchema, + verifyAgentCardSignature, + verifyAgentCardSignatures, + canonicalizeAgentCard, + AgentCardSignatureError, +} from './types/signature.js'; +export type { + AgentCardSignature, + MutualTlsSecurityScheme, + VerifyAgentCardSignatureOptions, +} from './types/signature.js'; diff --git a/packages/core/src/types/agent-card.test.ts b/packages/core/src/types/agent-card.test.ts index 1473a75..6b5e17a 100644 --- a/packages/core/src/types/agent-card.test.ts +++ b/packages/core/src/types/agent-card.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { AgentCardSchema } from './agent-card.js'; +import { AgentCardSchema, SecuritySchemeSchema } from './agent-card.js'; const validAgentCard = { name: 'Calculator Agent', @@ -51,4 +51,54 @@ describe('AgentCardSchema', () => { }); expect(result.success).toBe(false); }); + + it('validates a card with spec-compliant securitySchemes', () => { + const result = AgentCardSchema.safeParse({ + ...validAgentCard, + securitySchemes: { + apiKey: { type: 'apiKey', name: 'X-API-Key', in: 'header' }, + bearer: { type: 'http', scheme: 'Bearer', bearerFormat: 'JWT' }, + mtls: { type: 'mutualTLS', description: 'client certificate required' }, + oidc: { + type: 'openIdConnect', + openIdConnectUrl: 'https://issuer.example.com/.well-known/openid-configuration', + }, + oauth: { + type: 'oauth2', + flows: { + clientCredentials: { + tokenUrl: 'https://issuer.example.com/token', + scopes: { 'tasks:read': 'Read tasks' }, + }, + }, + }, + }, + }); + expect(result.success).toBe(true); + }); +}); + +describe('SecuritySchemeSchema', () => { + it('discriminates on the `type` field', () => { + expect(SecuritySchemeSchema.safeParse({ type: 'mutualTLS' }).success).toBe(true); + expect(SecuritySchemeSchema.safeParse({ type: 'http', scheme: 'Bearer' }).success).toBe(true); + }); + + it('rejects the legacy `scheme` discriminator and `httpScheme` field', () => { + expect( + SecuritySchemeSchema.safeParse({ scheme: 'apiKey', name: 'k', in: 'header' }).success, + ).toBe(false); + expect(SecuritySchemeSchema.safeParse({ type: 'http', httpScheme: 'bearer' }).success).toBe( + false, + ); + }); + + it('requires tokenUrl and scopes inside an oauth2 flow', () => { + expect( + SecuritySchemeSchema.safeParse({ + type: 'oauth2', + flows: { clientCredentials: { scopes: {} } }, + }).success, + ).toBe(false); + }); }); diff --git a/packages/core/src/types/agent-card.ts b/packages/core/src/types/agent-card.ts index 3f49cc3..1a9233b 100644 --- a/packages/core/src/types/agent-card.ts +++ b/packages/core/src/types/agent-card.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { AgentCardSignatureSchema, MutualTlsSecuritySchemeSchema } from './signature.js'; export const CapabilitySchema = z.object({ streaming: z.boolean().optional(), @@ -9,8 +10,11 @@ export const CapabilitySchema = z.object({ }); export type Capability = z.infer; +// Security schemes follow the A2A spec (§4.5), modeled on the OpenAPI 3.x +// Security Scheme Object and discriminated by the `type` field. + export const ApiKeySecuritySchemeSchema = z.object({ - scheme: z.literal('apiKey'), + type: z.literal('apiKey'), description: z.string().optional(), name: z.string(), in: z.enum(['header', 'query', 'cookie']), @@ -18,36 +22,75 @@ export const ApiKeySecuritySchemeSchema = z.object({ export type ApiKeySecurityScheme = z.infer; export const HttpSecuritySchemeSchema = z.object({ - scheme: z.literal('http'), + type: z.literal('http'), description: z.string().optional(), + // RFC 7235 HTTP Authentication scheme name (e.g. "Bearer", "Basic"). + scheme: z.string(), bearerFormat: z.string().optional(), - httpScheme: z.literal('bearer'), }); export type HttpSecurityScheme = z.infer; +// OAuth 2.0 flow definitions (A2A spec §4.5.7–4.5.10). +const OAuthScopesSchema = z.record(z.string()); + +export const AuthorizationCodeOAuthFlowSchema = z.object({ + authorizationUrl: z.string().url(), + tokenUrl: z.string().url(), + refreshUrl: z.string().url().optional(), + scopes: OAuthScopesSchema, +}); +export type AuthorizationCodeOAuthFlow = z.infer; + +export const ClientCredentialsOAuthFlowSchema = z.object({ + tokenUrl: z.string().url(), + refreshUrl: z.string().url().optional(), + scopes: OAuthScopesSchema, +}); +export type ClientCredentialsOAuthFlow = z.infer; + +export const ImplicitOAuthFlowSchema = z.object({ + authorizationUrl: z.string().url(), + refreshUrl: z.string().url().optional(), + scopes: OAuthScopesSchema, +}); +export type ImplicitOAuthFlow = z.infer; + +export const PasswordOAuthFlowSchema = z.object({ + tokenUrl: z.string().url(), + refreshUrl: z.string().url().optional(), + scopes: OAuthScopesSchema, +}); +export type PasswordOAuthFlow = z.infer; + +export const OAuthFlowsSchema = z.object({ + authorizationCode: AuthorizationCodeOAuthFlowSchema.optional(), + clientCredentials: ClientCredentialsOAuthFlowSchema.optional(), + implicit: ImplicitOAuthFlowSchema.optional(), + password: PasswordOAuthFlowSchema.optional(), +}); +export type OAuthFlows = z.infer; + export const OAuth2SecuritySchemeSchema = z.object({ - scheme: z.literal('oauth2'), + type: z.literal('oauth2'), description: z.string().optional(), - flows: z.record(z.unknown()), - authorizationEndpoint: z.string().url().optional(), - tokenEndpoint: z.string().url().optional(), - scopes: z.record(z.string()).optional(), + flows: OAuthFlowsSchema, + oauth2MetadataUrl: z.string().url().optional(), }); export type OAuth2SecurityScheme = z.infer; export const OpenIdConnectSecuritySchemeSchema = z.object({ - scheme: z.literal('openIdConnect'), + type: z.literal('openIdConnect'), description: z.string().optional(), openIdConnectUrl: z.string().url(), - scopes: z.record(z.string()).optional(), }); export type OpenIdConnectSecurityScheme = z.infer; -export const SecuritySchemeSchema = z.union([ +export const SecuritySchemeSchema = z.discriminatedUnion('type', [ ApiKeySecuritySchemeSchema, HttpSecuritySchemeSchema, OAuth2SecuritySchemeSchema, OpenIdConnectSecuritySchemeSchema, + MutualTlsSecuritySchemeSchema, ]); export type SecurityScheme = z.infer; @@ -97,7 +140,7 @@ export const AgentCardSchema = z.object({ defaultInputModes: z.array(z.string()), defaultOutputModes: z.array(z.string()), skills: z.array(SkillSchema), - signatures: z.array(z.unknown()).optional(), + signatures: z.array(AgentCardSignatureSchema).optional(), iconUrl: z.string().url().optional(), supportedInterfaces: z.array(AgentInterfaceSchema), extensions: z.array(z.unknown()).optional(), diff --git a/packages/core/src/types/requests.test.ts b/packages/core/src/types/requests.test.ts new file mode 100644 index 0000000..c579827 --- /dev/null +++ b/packages/core/src/types/requests.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from 'vitest'; +import { + CancelTaskRequestSchema, + GetExtendedAgentCardRequestSchema, + GetTaskRequestSchema, + ListTasksRequestSchema, + ListTasksResponseSchema, + SendMessageRequestSchema, + SubscribeToTaskRequestSchema, + TaskPushNotificationConfigSchema, +} from './requests.js'; + +const validMessage = { + messageId: 'msg-1', + role: 'user' as const, + parts: [{ kind: 'text' as const, text: 'hello' }], +}; + +describe('SendMessageRequestSchema', () => { + it('validates a valid send message request', () => { + const result = SendMessageRequestSchema.safeParse({ + message: validMessage, + }); + expect(result.success).toBe(true); + }); + + it('validates with optional fields', () => { + const result = SendMessageRequestSchema.safeParse({ + message: validMessage, + contextId: 'ctx-1', + historyLength: 10, + taskId: 'task-1', + }); + expect(result.success).toBe(true); + }); + + it('rejects missing message', () => { + const result = SendMessageRequestSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it('rejects invalid message', () => { + const result = SendMessageRequestSchema.safeParse({ + message: { messageId: 'msg-1' }, + }); + expect(result.success).toBe(false); + }); + + it('rejects non-integer historyLength', () => { + const result = SendMessageRequestSchema.safeParse({ + message: validMessage, + historyLength: 1.5, + }); + expect(result.success).toBe(false); + }); +}); + +describe('GetTaskRequestSchema', () => { + it('validates with just id', () => { + const result = GetTaskRequestSchema.safeParse({ id: 'task-1' }); + expect(result.success).toBe(true); + }); + + it('validates with optional historyLength', () => { + const result = GetTaskRequestSchema.safeParse({ + id: 'task-1', + historyLength: 5, + }); + expect(result.success).toBe(true); + }); + + it('rejects missing id', () => { + const result = GetTaskRequestSchema.safeParse({}); + expect(result.success).toBe(false); + }); +}); + +describe('ListTasksRequestSchema', () => { + it('validates an empty request', () => { + const result = ListTasksRequestSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it('validates with all optional fields', () => { + const result = ListTasksRequestSchema.safeParse({ + contextId: 'ctx-1', + status: 'completed', + pageSize: 20, + pageToken: 'token-abc', + historyLength: 3, + }); + expect(result.success).toBe(true); + }); + + it('rejects non-integer pageSize', () => { + const result = ListTasksRequestSchema.safeParse({ pageSize: 1.5 }); + expect(result.success).toBe(false); + }); +}); + +describe('ListTasksResponseSchema', () => { + it('validates a valid response', () => { + const result = ListTasksResponseSchema.safeParse({ + tasks: [{ id: 'task-1', status: { state: 'completed' } }], + nextPageToken: '', + totalSize: 1, + }); + expect(result.success).toBe(true); + }); +}); + +describe('CancelTaskRequestSchema', () => { + it('validates with id', () => { + const result = CancelTaskRequestSchema.safeParse({ id: 'task-1' }); + expect(result.success).toBe(true); + }); + + it('rejects missing id', () => { + const result = CancelTaskRequestSchema.safeParse({}); + expect(result.success).toBe(false); + }); +}); + +describe('SubscribeToTaskRequestSchema', () => { + it('validates with just id', () => { + const result = SubscribeToTaskRequestSchema.safeParse({ id: 'task-1' }); + expect(result.success).toBe(true); + }); + + it('validates with optional historyLength', () => { + const result = SubscribeToTaskRequestSchema.safeParse({ + id: 'task-1', + historyLength: 10, + }); + expect(result.success).toBe(true); + }); + + it('rejects missing id', () => { + const result = SubscribeToTaskRequestSchema.safeParse({}); + expect(result.success).toBe(false); + }); +}); + +describe('TaskPushNotificationConfigSchema', () => { + it('validates with just url', () => { + const result = TaskPushNotificationConfigSchema.safeParse({ + url: 'https://example.com/callback', + }); + expect(result.success).toBe(true); + }); + + it('validates with all optional fields', () => { + const result = TaskPushNotificationConfigSchema.safeParse({ + id: 'notif-1', + taskId: 'task-1', + url: 'https://example.com/callback', + token: 'secret-token', + authentication: { type: 'bearer' }, + }); + expect(result.success).toBe(true); + }); + + it('rejects invalid url', () => { + const result = TaskPushNotificationConfigSchema.safeParse({ + url: 'not-a-url', + }); + expect(result.success).toBe(false); + }); + + it('rejects missing url', () => { + const result = TaskPushNotificationConfigSchema.safeParse({}); + expect(result.success).toBe(false); + }); +}); + +describe('GetExtendedAgentCardRequestSchema', () => { + it('validates an empty request', () => { + const result = GetExtendedAgentCardRequestSchema.safeParse({}); + expect(result.success).toBe(true); + }); + + it('validates with tenant', () => { + const result = GetExtendedAgentCardRequestSchema.safeParse({ + tenant: 'acme-corp', + }); + expect(result.success).toBe(true); + }); +}); diff --git a/packages/core/src/types/signature.test.ts b/packages/core/src/types/signature.test.ts new file mode 100644 index 0000000..12f5549 --- /dev/null +++ b/packages/core/src/types/signature.test.ts @@ -0,0 +1,176 @@ +import { + FlattenedSign, + type GenerateKeyPairResult, + type CryptoKey as JoseCryptoKey, + base64url, + exportSPKI, + generateKeyPair, +} from 'jose'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { + type AgentCardSignature, + AgentCardSignatureSchema, + canonicalizeAgentCard, + verifyAgentCardSignature, + verifyAgentCardSignatures, +} from './signature.js'; + +const card: Record = { + name: 'Calculator Agent', + version: '1.0.0', + url: 'https://example.com/agent', +}; + +let rsa: GenerateKeyPairResult; +let rsaOther: GenerateKeyPairResult; +let ec: GenerateKeyPairResult; +let ed: GenerateKeyPairResult; +let rsaPublicPem: string; + +beforeAll(async () => { + rsa = await generateKeyPair('RS256'); + rsaOther = await generateKeyPair('RS256'); + ec = await generateKeyPair('ES256'); + ed = await generateKeyPair('EdDSA'); + rsaPublicPem = await exportSPKI(rsa.publicKey); +}); + +async function sign( + target: Record, + privateKey: JoseCryptoKey, + alg: string, + headerExtra: Record = {}, +): Promise { + const payload = new TextEncoder().encode(canonicalizeAgentCard(target)); + const jws = await new FlattenedSign(payload) + .setProtectedHeader({ alg, typ: 'JOSE', ...headerExtra }) + .sign(privateKey); + return { protected: jws.protected ?? '', signature: jws.signature }; +} + +function protectedHeader(header: Record): string { + return base64url.encode(new TextEncoder().encode(JSON.stringify(header))); +} + +describe('canonicalizeAgentCard', () => { + it('excludes the signatures field and sorts keys lexicographically', () => { + const result = canonicalizeAgentCard({ + b: 1, + a: 2, + signatures: [{ protected: 'x', signature: 'y' }], + }); + expect(result).toBe('{"a":2,"b":1}'); + }); + + it('omits undefined properties and recurses', () => { + const result = canonicalizeAgentCard({ a: { z: 1, y: undefined, x: [3, 2] }, m: undefined }); + expect(result).toBe('{"a":{"x":[3,2],"z":1}}'); + }); +}); + +describe('verifyAgentCardSignature', () => { + it('verifies a valid RS256 signature with a PEM public key', async () => { + const sig = await sign(card, rsa.privateKey, 'RS256', { kid: 'key-1' }); + expect(await verifyAgentCardSignature(card, sig, { key: rsaPublicPem })).toBe(true); + }); + + it('verifies a valid ES256 signature with a CryptoKey', async () => { + const sig = await sign(card, ec.privateKey, 'ES256'); + expect(await verifyAgentCardSignature(card, sig, { key: ec.publicKey })).toBe(true); + }); + + it('verifies a valid EdDSA (Ed25519) signature', async () => { + const sig = await sign(card, ed.privateKey, 'EdDSA'); + expect(await verifyAgentCardSignature(card, sig, { key: ed.publicKey })).toBe(true); + }); + + it('returns false when the card has been tampered with', async () => { + const sig = await sign(card, rsa.privateKey, 'RS256'); + const tampered = { ...card, url: 'https://evil.example.com' }; + expect(await verifyAgentCardSignature(tampered, sig, { key: rsa.publicKey })).toBe(false); + }); + + it('returns false when verified with the wrong key', async () => { + const sig = await sign(card, rsa.privateKey, 'RS256'); + expect(await verifyAgentCardSignature(card, sig, { key: rsaOther.publicKey })).toBe(false); + }); + + it('throws on a malformed protected header', async () => { + await expect( + verifyAgentCardSignature( + card, + { protected: '@@@not-base64-json', signature: 'AAAA' }, + { key: rsa.publicKey }, + ), + ).rejects.toThrow('Invalid JWS protected header'); + }); + + it('throws for an unsupported algorithm', async () => { + const sig = { protected: protectedHeader({ alg: 'FOO' }), signature: 'AAAA' }; + await expect(verifyAgentCardSignature(card, sig, { key: rsaPublicPem })).rejects.toThrow( + 'Unsupported or missing signature algorithm', + ); + }); + + it('throws when no key is available and remote keys are not allowed', async () => { + const sig = await sign(card, rsa.privateKey, 'RS256', { + jku: 'https://example.com/jwks.json', + }); + await expect(verifyAgentCardSignature(card, sig)).rejects.toThrow( + 'No verification key available', + ); + }); +}); + +describe('verifyAgentCardSignatures', () => { + it('returns valid for multiple valid signatures', async () => { + const a = await sign(card, rsa.privateKey, 'RS256'); + const b = await sign(card, rsa.privateKey, 'RS256', { kid: 'key-2' }); + const result = await verifyAgentCardSignatures(card, [a, b], { key: rsa.publicKey }); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('collects errors from invalid signatures, indexed', async () => { + const valid = await sign(card, rsa.privateKey, 'RS256'); + const invalid = await sign(card, rsaOther.privateKey, 'RS256'); + const result = await verifyAgentCardSignatures(card, [valid, invalid], { key: rsa.publicKey }); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].message).toContain('Signature 1 is invalid'); + expect(result.errors[0].signatureIndex).toBe(1); + }); + + it('captures thrown structural errors with the right index', async () => { + const ok = await sign(card, rsa.privateKey, 'RS256'); + const bad = { protected: protectedHeader({ alg: 'FOO' }), signature: 'AAAA' }; + const result = await verifyAgentCardSignatures(card, [ok, bad], { key: rsa.publicKey }); + expect(result.valid).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].signatureIndex).toBe(1); + expect(result.errors[0].message).toContain('Unsupported or missing signature algorithm'); + }); +}); + +describe('AgentCardSignatureSchema', () => { + it('validates a JWS signature object', () => { + expect(() => + AgentCardSignatureSchema.parse({ + protected: 'eyJ...', + signature: 'abc', + header: { kid: 'k' }, + }), + ).not.toThrow(); + }); + + it('validates without the optional unprotected header', () => { + expect(() => + AgentCardSignatureSchema.parse({ protected: 'eyJ...', signature: 'abc' }), + ).not.toThrow(); + }); + + it('rejects a signature missing required fields', () => { + expect(() => AgentCardSignatureSchema.parse({ protected: 'eyJ...' })).toThrow(); + expect(() => AgentCardSignatureSchema.parse({})).toThrow(); + }); +}); diff --git a/packages/core/src/types/signature.ts b/packages/core/src/types/signature.ts new file mode 100644 index 0000000..3ea56c4 --- /dev/null +++ b/packages/core/src/types/signature.ts @@ -0,0 +1,241 @@ +/** + * Agent Card signature verification utilities. + * + * Implements A2A spec §8.4 (Agent Card Signing): signatures are JSON Web + * Signatures (JWS, RFC 7515) computed over the RFC 8785 (JCS) canonicalization + * of the Agent Card with its `signatures` field excluded. Verification is + * performed with `jose`, which is WebCrypto-based and works in Node.js, edge + * runtimes, and browsers. + * + * @module + */ +import { base64url, createRemoteJWKSet, flattenedVerify, importSPKI, importX509 } from 'jose'; +import type { FlattenedVerifyGetKey, JWK, CryptoKey as JoseCryptoKey, KeyObject } from 'jose'; +import { z } from 'zod'; +import { A2AError } from './errors.js'; + +/** + * An Agent Card signature in JWS form (A2A spec §4.4.7 / §8.4.2). + * + * - `protected`: base64url-encoded JWS Protected Header (must contain `alg`). + * - `signature`: base64url-encoded signature value. + * - `header`: optional JWS Unprotected Header (plain JSON, not encoded). + */ +export const AgentCardSignatureSchema = z.object({ + protected: z.string().min(1), + signature: z.string().min(1), + header: z.record(z.unknown()).optional(), +}); + +export type AgentCardSignature = z.infer; + +/** + * mTLS security scheme (A2A spec §4.5.6 — `MutualTlsSecurityScheme`). + * Carries only an optional human-readable description; the certificate trust + * configuration is established out of band at the transport layer. + */ +export const MutualTlsSecuritySchemeSchema = z.object({ + type: z.literal('mutualTLS'), + description: z.string().optional(), +}); + +export type MutualTlsSecurityScheme = z.infer; + +export class AgentCardSignatureError extends A2AError { + constructor( + message: string, + public readonly signatureIndex?: number, + ) { + super('AgentCardSignatureError', message, { signatureIndex }); + } +} + +/** Algorithms accepted during verification (guards against `alg: none` / confusion). */ +const ALLOWED_ALGORITHMS = [ + 'RS256', + 'RS384', + 'RS512', + 'PS256', + 'PS384', + 'PS512', + 'ES256', + 'ES384', + 'ES512', + 'EdDSA', + 'Ed25519', +]; + +/** A static verification key. */ +type VerifyKeyMaterial = JoseCryptoKey | KeyObject | JWK | Uint8Array; +/** Either a static key or a `jose` key-resolver function. */ +type VerifyKey = VerifyKeyMaterial | FlattenedVerifyGetKey; + +export interface VerifyAgentCardSignatureOptions { + /** + * A trusted verification key: a PEM-encoded SPKI public key or X.509 + * certificate (string), an imported `CryptoKey`, or a key-resolver function + * such as the one returned by `jose`'s `createRemoteJWKSet`. + */ + key?: VerifyKey | string; + /** + * When `true`, and no explicit `key` is supplied, fetch the JWKS from the + * protected header's `jku` URL. Defaults to `false` to avoid SSRF: callers + * must opt in to outbound key fetching from card-controlled URLs. + */ + allowRemoteKeys?: boolean; +} + +/** + * Canonicalize an Agent Card for signing/verification per A2A spec §8.4.1. + * + * Excludes the `signatures` field, then serializes using the RFC 8785 (JCS) + * rules: object keys sorted by UTF-16 code unit, no insignificant whitespace, + * and standard JSON value serialization. Properties whose value is `undefined` + * are omitted (mirroring JSON / Protobuf field-presence semantics). + */ +export function canonicalizeAgentCard(card: Record): string { + const { signatures: _signatures, ...rest } = card; + return jcsStringify(rest); +} + +function jcsStringify(value: unknown): string { + if (value === null) return 'null'; + const t = typeof value; + if (t === 'number') { + if (!Number.isFinite(value as number)) { + throw new AgentCardSignatureError('Cannot canonicalize non-finite number'); + } + return JSON.stringify(value); + } + if (t === 'boolean' || t === 'string') return JSON.stringify(value); + if (Array.isArray(value)) { + return `[${value.map((v) => jcsStringify(v === undefined ? null : v)).join(',')}]`; + } + if (t === 'object') { + const obj = value as Record; + const keys = Object.keys(obj) + .filter((k) => obj[k] !== undefined) + .sort(); + const entries = keys.map((k) => `${JSON.stringify(k)}:${jcsStringify(obj[k])}`); + return `{${entries.join(',')}}`; + } + throw new AgentCardSignatureError(`Value of type ${t} is not JSON-serializable`); +} + +function decodeProtectedHeader(encoded: string): Record { + try { + const json = new TextDecoder().decode(base64url.decode(encoded)); + const header = JSON.parse(json); + if (typeof header !== 'object' || header === null) { + throw new Error('not an object'); + } + return header as Record; + } catch { + throw new AgentCardSignatureError('Invalid JWS protected header'); + } +} + +async function resolveKey( + header: Record, + options: VerifyAgentCardSignatureOptions | undefined, +): Promise { + const alg = typeof header.alg === 'string' ? header.alg : undefined; + if (!alg) { + throw new AgentCardSignatureError('JWS protected header is missing the "alg" parameter'); + } + + const provided = options?.key; + if (typeof provided === 'string') { + return provided.includes('CERTIFICATE') + ? await importX509(provided, alg) + : await importSPKI(provided, alg); + } + if (provided) return provided; + + if (options?.allowRemoteKeys && typeof header.jku === 'string') { + return createRemoteJWKSet(new URL(header.jku)); + } + + throw new AgentCardSignatureError( + 'No verification key available: pass options.key, or set allowRemoteKeys to fetch from the protected header "jku"', + ); +} + +/** + * Verify a single Agent Card signature (A2A spec §8.4.3). + * + * @returns `true` if the signature is cryptographically valid, `false` if the + * signature does not match. Throws {@link AgentCardSignatureError} for + * structural problems (malformed header, unavailable/disallowed key, + * unsupported algorithm). + */ +export async function verifyAgentCardSignature( + card: Record, + signature: AgentCardSignature, + options?: VerifyAgentCardSignatureOptions, +): Promise { + const header = decodeProtectedHeader(signature.protected); + const alg = header.alg; + if (typeof alg !== 'string' || !ALLOWED_ALGORITHMS.includes(alg)) { + throw new AgentCardSignatureError(`Unsupported or missing signature algorithm: ${String(alg)}`); + } + + const key = await resolveKey(header, options); + const payload = base64url.encode(new TextEncoder().encode(canonicalizeAgentCard(card))); + const jws = { protected: signature.protected, payload, signature: signature.signature }; + const verifyOptions = { algorithms: ALLOWED_ALGORITHMS }; + + try { + if (typeof key === 'function') { + await flattenedVerify(jws, key, verifyOptions); + } else { + await flattenedVerify(jws, key, verifyOptions); + } + return true; + } catch (err) { + // A mathematically-invalid signature is an expected "false", not an error. + if ((err as { code?: string })?.code === 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') { + return false; + } + throw new AgentCardSignatureError( + `Signature verification error: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + +/** + * Verify all signatures on an Agent Card. Returns `valid: true` only when every + * signature verifies; otherwise `errors` describes each failure by index. + */ +export async function verifyAgentCardSignatures( + card: Record, + signatures: AgentCardSignature[], + options?: VerifyAgentCardSignatureOptions, +): Promise<{ valid: boolean; errors: AgentCardSignatureError[] }> { + const errors: AgentCardSignatureError[] = []; + + for (let i = 0; i < signatures.length; i++) { + try { + const isValid = await verifyAgentCardSignature(card, signatures[i], options); + if (!isValid) { + errors.push(new AgentCardSignatureError(`Signature ${i} is invalid`, i)); + } + } catch (err) { + if (err instanceof AgentCardSignatureError) { + errors.push(new AgentCardSignatureError(err.message, i)); + } else { + errors.push( + new AgentCardSignatureError( + `Signature ${i} verification failed: ${err instanceof Error ? err.message : String(err)}`, + i, + ), + ); + } + } + } + + return { + valid: errors.length === 0, + errors, + }; +} diff --git a/packages/mcp-bridge/README.md b/packages/mcp-bridge/README.md index a30b04d..3580c50 100644 --- a/packages/mcp-bridge/README.md +++ b/packages/mcp-bridge/README.md @@ -1,197 +1,38 @@ # @reaatech/a2a-reference-mcp-bridge -[![npm version](https://img.shields.io/npm/v/@reaatech/a2a-reference-mcp-bridge.svg)](https://www.npmjs.com/package/@reaatech/a2a-reference-mcp-bridge) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/reaatech/a2a-reference-ts/blob/main/LICENSE) -[![CI](https://img.shields.io/github/actions/workflow/status/reaatech/a2a-reference-ts/ci.yml?branch=main&label=CI)](https://github.com/reaatech/a2a-reference-ts/actions/workflows/ci.yml) +Bidirectional A2A ↔ MCP protocol adapter. -> **Status:** Pre-1.0 — APIs may change in minor versions. Pin to a specific version in production. +## A2A → MCP: Call MCP tools from A2A agents -Bidirectional protocol adapter bridging the A2A (Agent-to-Agent) and MCP (Model Context Protocol) ecosystems. Expose A2A agent skills as MCP tools, and MCP tools as A2A skills — enabling interoperability between agent frameworks. - -## Installation - -```bash -npm install @reaatech/a2a-reference-mcp-bridge @modelcontextprotocol/sdk -# or -pnpm add @reaatech/a2a-reference-mcp-bridge @modelcontextprotocol/sdk -``` - -## Feature Overview - -- **A2A → MCP** — wrap a remote A2A agent behind an MCP server, exposing its skills as MCP tools -- **MCP → A2A** — wrap MCP tools as A2A skills, making them available to A2A clients and orchestrators -- **Task polling and streaming** — both task completion strategies supported for A2A → MCP -- **Input-required handling** — leverages MCP sampling for interactive tool calls -- **Automatic schema mapping** — skill parameters ↔ MCP `inputSchema`, artifacts ↔ MCP content -- **Structured logging** — Pino-based logging for observability - -## Quick Start - -### A2A Agent as MCP Server - -Expose a remote A2A agent's skills as MCP tools, consumable by MCP clients like Claude Desktop: - -```typescript -import { A2aAsMcpServer } from "@reaatech/a2a-reference-mcp-bridge"; - -const server = new A2aAsMcpServer({ - a2aAgentUrl: "http://localhost:3000", - name: "my-a2a-bridge", - version: "1.0.0", -}); - -await server.initialize(); // Fetches agent card, registers MCP tool handlers -await server.run(); // Connects via stdio — ready for MCP clients -``` - -### MCP Tools as A2A Skills - -Wrap MCP tools behind an A2A agent card, making them callable from the A2A ecosystem: - -```typescript -import { McpToolAdapter } from "@reaatech/a2a-reference-mcp-bridge"; -import { createA2AExpressApp } from "@reaatech/a2a-reference-server"; +```ts +import { McpToolAdapter } from '@reaatech/a2a-reference-mcp-bridge'; const adapter = new McpToolAdapter({ - mcpTransport: myMcpTransport, - agentCardBase: { - name: "MCP Bridge Agent", - description: "Exposes MCP tools via A2A", - url: "http://localhost:3004", - version: "1.0.0", - protocolVersion: "0.3.0", - capabilities: { streaming: false }, - defaultInputModes: ["text/plain"], - defaultOutputModes: ["text/plain"], - supportedInterfaces: [], - }, + serverUrl: 'http://localhost:3001/sse', + clientId: 'a2a-agent', }); - await adapter.initialize(); -const agentCard = adapter.getAgentCard(); // Skills auto-populated from MCP tools - -const executor: AgentExecutor = { - async execute(context, eventBus) { - eventBus.emitStatusUpdate({ kind: "status", status: { state: "working" } }); - const artifacts = await adapter.executeTask(context.task, context.message); - for (const artifact of artifacts) { - eventBus.emitArtifactUpdate({ kind: "artifact", artifact }); - } - eventBus.emitStatusUpdate({ kind: "status", status: { state: "completed" } }); - }, -}; - -const app = createA2AExpressApp({ agentCard, executor }); -app.listen(3004); +const enrichedCard = adapter.getAgentCard(); // adds MCP tools as skills ``` -## API Reference - -### `A2aAsMcpServer` (A2A → MCP) - -Exposes an A2A agent behind an MCP server interface. - -```typescript -class A2aAsMcpServer { - constructor(options: A2aAsMcpServerOptions); - - initialize(): Promise; // Fetch card, register tools - run(): Promise; // Connect via stdio - close(): Promise; // Close MCP connection -} -``` +## MCP → A2A: Expose A2A agent as MCP server -#### `A2aAsMcpServerOptions` +```ts +import { A2aAsMcpServer } from '@reaatech/a2a-reference-mcp-bridge'; -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `a2aAgentUrl` | `string` | (required) | Base URL of the remote A2A agent | -| `name` | `string` | `"a2a-bridge-mcp-server"` | MCP server name | -| `version` | `string` | `"0.1.0"` | MCP server version | -| `maxPolls` | `number` | `60` | Max polling iterations before timeout | -| `pollIntervalMs` | `number` | `500` | Milliseconds between poll attempts | - -#### How It Works - -1. **`initialize()`** fetches the A2A agent card, maps each `Skill` to an MCP `Tool`: - - Tool `name` = skill `id` - - Tool `description` = skill `description` - - Tool `inputSchema` = skill `parameters` (or a generic `{ input: string }` fallback) - -2. **On tool call**, sends an A2A `Message` and waits for task completion via: - - **Polling** (default): repeatedly calls `getTask()` until terminal state or `maxPolls` reached - - **Streaming** (if `capabilities.streaming` is `true`): subscribes to SSE events - -3. **Result mapping** converts A2A artifacts to MCP content: - - `TextPart` → `{ type: "text", text }` - - `FilePart` → `{ type: "text", text: "[File: name]\n" }` - - `DataPart` → `{ type: "text", text: JSON.stringify(data) }` - -4. **`input-required` handling**: requests MCP sampling from the client for LLM-generated input - -### `McpToolAdapter` (MCP → A2A) - -Wraps MCP tools behind an A2A agent card. - -```typescript -class McpToolAdapter { - constructor(options: McpToolAdapterOptions); - - initialize(): Promise; // Connect MCP client, list tools - getAgentCard(): AgentCard; // Agent card with MCP tools as skills - executeTask(task: Task, message: Message): Promise; // Invoke MCP tool - disconnect(): Promise; // Close MCP client -} +const mcpServer = new A2aAsMcpServer({ + a2aAgentUrl: 'https://agent.example.com', +}); +await mcpServer.start({ transport: 'stdio' }); ``` -#### `McpToolAdapterOptions` - -| Property | Type | Description | -|----------|------|-------------| -| `mcpTransport` | `Transport` (MCP SDK) | Transport layer to the MCP server | -| `agentCardBase` | `Omit` | Base agent card (skills are auto-populated) | - -#### How It Works - -1. **`initialize()`** connects to the MCP server and calls `tools/list`, mapping each tool to an A2A `Skill`: - - `skill.id` = tool `name` - - `skill.name` = tool `name` - - `skill.tags` = `["mcp"]` - - `skill.parameters` = tool `inputSchema` - -2. **`executeTask()`** determines which tool to call via heuristic: - - Checks if the first text part starts with a known skill ID - - Checks for explicit `{ skill: "toolName" }` in a `DataPart` - - Falls back to the first skill if only one exists - -3. **Argument extraction**: - - `DataPart` payload used directly - - JSON text body parsed as structured arguments - - Plain text wrapped as `{ input: text }` - -4. **Result mapping** converts MCP content to A2A artifacts: - - `TextContent` → `{ kind: "text", text }` - - `ImageContent` → `{ kind: "file", file: { bytes, mimeType } }` - - Failed tool calls throw `McpToolCallError` - -## Bridge Direction Summary - -| Direction | Class | Use Case | -|-----------|-------|----------| -| A2A → MCP | `A2aAsMcpServer` | Make A2A agents available to MCP clients (Claude Desktop, Cursor, etc.) | -| MCP → A2A | `McpToolAdapter` | Make MCP tools available to A2A orchestrators and agent workflows | - -## Example: Complete Bridge Setup - -See the [`04-mcp-bridge` example](https://github.com/reaatech/a2a-reference-ts/tree/main/examples/04-mcp-bridge) in the repository for a working end-to-end demonstration. - -## Related Packages - -- [`@reaatech/a2a-reference-core`](https://www.npmjs.com/package/@reaatech/a2a-reference-core) — Protocol types and Zod schemas -- [`@reaatech/a2a-reference-client`](https://www.npmjs.com/package/@reaatech/a2a-reference-client) — A2A client SDK (used internally) -- [`@reaatech/a2a-reference-server`](https://www.npmjs.com/package/@reaatech/a2a-reference-server) — A2A server framework -- [`@reaatech/a2a-reference-observability`](https://www.npmjs.com/package/@reaatech/a2a-reference-observability) — Pino-based logging - -## License +## Schema Mapping -[MIT](https://github.com/reaatech/a2a-reference-ts/blob/main/LICENSE) +| MCP Concept | A2A Concept | +|-------------|-------------| +| `tools/list` | `skills` array in Agent Card | +| Tool input schema | Skill parameters (JSON Schema) | +| `tools/call` result | Artifact with `parts` | +| Resource | Artifact with URI addressing | +| Prompt | Message template | +| Sampling | `input-required` task state | diff --git a/packages/observability/README.md b/packages/observability/README.md index 6270bbc..645a192 100644 --- a/packages/observability/README.md +++ b/packages/observability/README.md @@ -1,177 +1,56 @@ # @reaatech/a2a-reference-observability -[![npm version](https://img.shields.io/npm/v/@reaatech/a2a-reference-observability.svg)](https://www.npmjs.com/package/@reaatech/a2a-reference-observability) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/reaatech/a2a-reference-ts/blob/main/LICENSE) -[![CI](https://img.shields.io/github/actions/workflow/status/reaatech/a2a-reference-ts/ci.yml?branch=main&label=CI)](https://github.com/reaatech/a2a-reference-ts/actions/workflows/ci.yml) +Structured logging, tracing, and metrics for A2A agents. -> **Status:** Pre-1.0 — APIs may change in minor versions. Pin to a specific version in production. +## Logging -Structured logging and tracing utilities for A2A agents, built on [Pino](https://github.com/pinojs/pino) (v9). Provides a zero-config default logger and factory functions for creating context-aware loggers with correlation ID propagation. +Pino-based structured logging with correlation IDs: -## Installation +```ts +import { createLogger, withCorrelationId, defaultLogger } from '@reaatech/a2a-reference-observability'; -```bash -npm install @reaatech/a2a-reference-observability -# or -pnpm add @reaatech/a2a-reference-observability -``` - -## Feature Overview - -- **Structured JSON logging** — Pino-powered, fast and low-overhead -- **Automatic pretty-printing** — human-readable output in development, raw JSON in production -- **Correlation ID propagation** — trace requests across async boundaries via child loggers -- **Named loggers** — identify which component emitted each log line -- **Pre-configured default** — import and use immediately with sensible defaults - -## Quick Start - -```typescript -import { defaultLogger, createLogger, withCorrelationId } from "@reaatech/a2a-reference-observability"; - -// Use the pre-configured default logger -defaultLogger.info("Agent started"); -defaultLogger.warn({ port: 3000 }, "Server is listening"); +const logger = createLogger({ name: 'my-agent', level: 'info' }); +logger.info({ taskId: 'abc' }, 'Task created'); -// Create a custom logger -const logger = createLogger({ - name: "my-agent", - level: "debug", -}); - -logger.debug({ requestId: "abc123" }, "Processing request"); - -// Propagate correlation ID for request tracing -const requestLogger = withCorrelationId(logger, "req-abc123"); -requestLogger.info("Starting task execution"); -// → {"name":"my-agent","level":"INFO","correlationId":"req-abc123","msg":"Starting task execution"} +// With correlation ID +const traced = withCorrelationId(logger, 'corr-123'); +traced.info('Processing'); ``` -## API Reference +## Telemetry -### `defaultLogger` +The package provides OpenTelemetry abstractions with no-op defaults. To instrument your agent, provide a custom `TelemetryProvider`: -A ready-to-use Pino `Logger` instance configured with defaults (`name: "a2a"`, `level: "info"`). Import and log immediately without any setup. +```ts +import { setTelemetryProvider, getTracer, getMeter } from '@reaatech/a2a-reference-observability'; +import type { TelemetryProvider, TelemetrySpan, TelemetryTracer, TelemetryMeter } from '@reaatech/a2a-reference-observability'; -```typescript -import { defaultLogger } from "@reaatech/a2a-reference-observability"; +// Use no-ops by default (nothing to configure) +const tracer = getTracer('my-agent'); +const meter = getMeter('my-agent'); -defaultLogger.info("Ready"); -defaultLogger.error({ err: new Error("boom") }, "Something went wrong"); -``` - -### `createLogger(options?: LoggerOptions): Logger` +// Create metrics +const taskCounter = meter.createCounter('a2a.tasks.total', { description: 'Total tasks' }); +const durationHistogram = meter.createHistogram('a2a.tasks.duration', { unit: 'ms' }); -Creates a configured Pino logger instance. +// Trace operations +import { withTaskSpan } from '@reaatech/a2a-reference-observability'; -```typescript -const logger = createLogger({ - name: "task-executor", - level: "trace", - correlationId: "corr-456", +await withTaskSpan(tracer, taskId, 'execute', async (span) => { + span.setAttribute('skill.id', 'echo'); + // your logic here }); ``` -#### `LoggerOptions` - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `name` | `string` | `"a2a"` | Logger name, included in every log line | -| `level` | `string` | `"info"` | Minimum log level (`trace`, `debug`, `info`, `warn`, `error`, `fatal`) | -| `correlationId` | `string` | — | Optional ID propagated to child loggers for request tracing | - -#### Transport Behavior - -- **Development** (`NODE_ENV !== "production"`): enables `pino-pretty` with colorized output -- **Production** (`NODE_ENV === "production"`): raw JSON output for log aggregators (Datadog, CloudWatch, ELK, etc.) - -### `withCorrelationId(logger: Logger, correlationId: string): Logger` - -Wraps an existing logger with a `correlationId` binding, returning a child logger. All subsequent log entries from the child automatically include the correlation ID. - -```typescript -async function handleRequest(req: Request) { - const correlationId = req.headers["x-request-id"] ?? crypto.randomUUID(); - const logger = withCorrelationId(defaultLogger, correlationId); - - logger.info("Request received"); - - // Logs from nested calls also carry the correlation ID - await processTask(logger); -} -``` - -This is useful for: -- Tracing a request across service boundaries -- Associating logs from async work with the originating request -- Debugging distributed workflows - -### `Logger` (type) - -Re-exported Pino `Logger` type for use in type annotations: - -```typescript -import type { Logger } from "@reaatech/a2a-reference-observability"; - -class MyService { - constructor(private logger: Logger) {} -} -``` - -## Usage Patterns - -### Structured Context - -Pino encourages attaching structured data as the first argument: +### Custom Provider -```typescript -logger.info({ taskId: "task-123", state: "working" }, "Task state changed"); -// → {"name":"a2a","level":"INFO","taskId":"task-123","state":"working","msg":"Task state changed"} -``` - -### Error Logging +```ts +import { setTelemetryProvider, type TelemetryProvider } from '@reaatech/a2a-reference-observability'; -Pass errors via the `err` key for automatic stack trace serialization: +const myProvider: TelemetryProvider = { + getTracer(name, version) { /* return OpenTelemetry tracer */ }, + getMeter(name, version) { /* return OpenTelemetry meter */ }, +}; -```typescript -try { - await riskyOperation(); -} catch (err) { - logger.error({ err, taskId: "task-123" }, "Task execution failed"); -} +setTelemetryProvider(myProvider); ``` - -### Child Loggers for Component Isolation - -```typescript -const baseLogger = createLogger({ name: "agent" }); - -const httpLogger = baseLogger.child({ component: "http" }); -const taskLogger = baseLogger.child({ component: "task-executor" }); - -httpLogger.info("Server started"); -// → {"name":"agent","component":"http","level":"INFO","msg":"Server started"} - -taskLogger.info("Executing task"); -// → {"name":"agent","component":"task-executor","level":"INFO","msg":"Executing task"} -``` - -## Integration with the Server - -The `@reaatech/a2a-reference-mcp-bridge` package uses this package for structured logging: - -```typescript -import { createLogger } from "@reaatech/a2a-reference-observability"; - -const logger = createLogger({ name: "mcp-bridge", level: "info" }); -logger.info({ a2aAgentUrl: url }, "Initializing A2A-as-MCP bridge"); -``` - -## Related Packages - -- [`@reaatech/a2a-reference-server`](https://www.npmjs.com/package/@reaatech/a2a-reference-server) — A2A server framework -- [`@reaatech/a2a-reference-mcp-bridge`](https://www.npmjs.com/package/@reaatech/a2a-reference-mcp-bridge) — A2A ↔ MCP bridge (uses this package) - -## License - -[MIT](https://github.com/reaatech/a2a-reference-ts/blob/main/LICENSE) diff --git a/packages/observability/src/index.ts b/packages/observability/src/index.ts index 78fa6f2..ebc5b14 100644 --- a/packages/observability/src/index.ts +++ b/packages/observability/src/index.ts @@ -1,37 +1,22 @@ -import { type Logger, pino } from 'pino'; - -export type { Logger }; - -export interface LoggerOptions { - name?: string; - level?: string; - correlationId?: string; -} - -export function createLogger(options?: LoggerOptions): Logger { - const logger = pino({ - name: options?.name ?? 'a2a', - level: options?.level ?? 'info', - transport: - process.env.NODE_ENV !== 'production' - ? { - target: 'pino-pretty', - options: { - colorize: true, - }, - } - : undefined, - }); - - if (options?.correlationId) { - return logger.child({ correlationId: options.correlationId }); - } - - return logger; -} - -export function withCorrelationId(logger: Logger, correlationId: string): Logger { - return logger.child({ correlationId }); -} - -export const defaultLogger: Logger = createLogger(); +export type { Logger } from 'pino'; +export { createLogger, withCorrelationId, defaultLogger } from './logger.js'; +export type { LoggerOptions } from './logger.js'; +export { + setTelemetryProvider, + getTelemetryProvider, + getTracer, + getMeter, + createTaskCounter, + createTaskDurationHistogram, + withTaskSpan, + NoopSpan, +} from './telemetry.js'; +export type { + TelemetryProvider, + TelemetryTracer, + TelemetrySpan, + TelemetryMeter, + TelemetryCounter, + TelemetryHistogram, + TelemetryGauge, +} from './telemetry.js'; diff --git a/packages/observability/src/logger.ts b/packages/observability/src/logger.ts new file mode 100644 index 0000000..a150f9e --- /dev/null +++ b/packages/observability/src/logger.ts @@ -0,0 +1,52 @@ +import { type Logger, type TransportMultiOptions, type TransportSingleOptions, pino } from 'pino'; + +export type { Logger }; + +export interface LoggerOptions { + name?: string; + level?: string; + correlationId?: string; + /** + * Pino transport configuration. When provided, it is passed directly to + * `pino({ transport })`. If omitted and `NODE_ENV !== 'production'`, the + * default pino-pretty transport is used. + * + * **Serverless/edge limitation:** The default pino-pretty transport uses + * `pino.transport()` which spawns a worker thread. This will fail in + * environments that do not support worker threads (Cloudflare Workers, + * Deno Deploy, some AWS Lambda configurations). For those runtimes set + * `NODE_ENV=production` or pass `{ transport: undefined }` and pipe the + * raw JSON output through a separate prettifier process. + */ + transport?: TransportSingleOptions | TransportMultiOptions | undefined; +} + +export function createLogger(options?: LoggerOptions): Logger { + const transport = + 'transport' in (options ?? {}) + ? options?.transport + : process.env.NODE_ENV !== 'production' + ? ({ + target: 'pino-pretty', + options: { colorize: true }, + } as TransportSingleOptions) + : undefined; + + const logger = pino({ + name: options?.name ?? 'a2a', + level: options?.level ?? 'info', + transport, + }); + + if (options?.correlationId) { + return logger.child({ correlationId: options.correlationId }); + } + + return logger; +} + +export function withCorrelationId(logger: Logger, correlationId: string): Logger { + return logger.child({ correlationId }); +} + +export const defaultLogger: Logger = createLogger(); diff --git a/packages/observability/src/telemetry.test.ts b/packages/observability/src/telemetry.test.ts new file mode 100644 index 0000000..bedce43 --- /dev/null +++ b/packages/observability/src/telemetry.test.ts @@ -0,0 +1,328 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + NoopSpan, + createTaskCounter, + createTaskDurationHistogram, + getMeter, + getTelemetryProvider, + getTracer, + setTelemetryProvider, + withTaskSpan, +} from './index.js'; +import type { TelemetryMeter, TelemetryProvider, TelemetrySpan, TelemetryTracer } from './index.js'; + +describe('NoopSpan', () => { + it('returns false from isRecording', () => { + const span = new NoopSpan(); + expect(span.isRecording()).toBe(false); + }); + + it('does not throw on any method', () => { + const span = new NoopSpan(); + expect(() => { + span.end(); + span.setAttribute('key', 'value'); + span.setAttributes({ a: 1, b: 'two' }); + span.addEvent('event', { detail: 'info' }); + span.setStatus({ code: 'ok' }); + }).not.toThrow(); + }); +}); + +describe('NoopProvider', () => { + let originalProvider: TelemetryProvider; + + beforeEach(() => { + originalProvider = getTelemetryProvider(); + }); + + afterEach(() => { + setTelemetryProvider(originalProvider); + }); + + it('is used as the default provider', () => { + const tracer = getTracer(); + const span = tracer.startSpan('test'); + expect(span.isRecording()).toBe(false); + }); + + it('getTracer returns a tracer', () => { + const tracer = getTracer(); + expect(tracer).toBeDefined(); + expect(typeof tracer.startSpan).toBe('function'); + }); + + it('getMeter returns a meter', () => { + const meter = getMeter(); + expect(meter).toBeDefined(); + expect(typeof meter.createCounter).toBe('function'); + }); + + it('startActiveSpan invokes the callback and ends the span', async () => { + const tracer = getTracer(); + const spy = vi.fn(); + + await tracer.startActiveSpan('test', async (span) => { + expect(span.isRecording()).toBe(false); + span.end(); + spy(); + return 'done'; + }); + + expect(spy).toHaveBeenCalledOnce(); + }); + + it('startActiveSpan works with options overload', async () => { + const tracer = getTracer(); + + const result = await tracer.startActiveSpan( + 'test', + { attributes: { key: 'val' }, kind: 'internal' }, + async (_span) => 'ok', + ); + + expect(result).toBe('ok'); + }); +}); + +describe('setTelemetryProvider', () => { + let originalProvider: TelemetryProvider; + + beforeEach(() => { + originalProvider = getTelemetryProvider(); + }); + + afterEach(() => { + setTelemetryProvider(originalProvider); + }); + + it('replaces the global provider', () => { + const mockTracer: TelemetryTracer = { + startSpan() { + return new NoopSpan(); + }, + async startActiveSpan(_name: string, ...args: unknown[]) { + const fn = args.length === 2 ? args[1] : args[0]; + const result = await (fn as (span: TelemetrySpan) => Promise)(new NoopSpan()); + return result; + }, + }; + const mockMeter: TelemetryMeter = { + createCounter() { + return { add() {} }; + }, + createHistogram() { + return { record() {} }; + }, + createGauge() { + return { record() {} }; + }, + }; + const provider: TelemetryProvider = { + getTracer() { + return mockTracer; + }, + getMeter() { + return mockMeter; + }, + }; + + setTelemetryProvider(provider); + + expect(getTelemetryProvider()).toBe(provider); + expect(getTracer()).toBe(mockTracer); + expect(getMeter()).toBe(mockMeter); + }); +}); + +describe('getTracer', () => { + let originalProvider: TelemetryProvider; + + beforeEach(() => { + originalProvider = getTelemetryProvider(); + }); + + afterEach(() => { + setTelemetryProvider(originalProvider); + }); + + it('passes name and version to provider', () => { + const spy = vi.fn<(...args: unknown[]) => ReturnType>(); + const provider: TelemetryProvider = { + getTracer: spy, + getMeter() { + return { + createCounter() { + return { add() {} }; + }, + createHistogram() { + return { record() {} }; + }, + createGauge() { + return { record() {} }; + }, + }; + }, + }; + setTelemetryProvider(provider); + + getTracer('my-tracer', '1.0.0'); + expect(spy).toHaveBeenCalledWith('my-tracer', '1.0.0'); + }); +}); + +describe('getMeter', () => { + let originalProvider: TelemetryProvider; + + beforeEach(() => { + originalProvider = getTelemetryProvider(); + }); + + afterEach(() => { + setTelemetryProvider(originalProvider); + }); + + it('passes name and version to provider', () => { + const spy = vi.fn<(...args: unknown[]) => ReturnType>(); + const provider: TelemetryProvider = { + getTracer() { + return { + startSpan() { + return new NoopSpan(); + }, + async startActiveSpan(_name: string, ...args: unknown[]) { + const fn = args.length === 2 ? args[1] : args[0]; + return (fn as (span: TelemetrySpan) => Promise)(new NoopSpan()); + }, + }; + }, + getMeter: spy, + }; + setTelemetryProvider(provider); + + getMeter('my-meter', '2.0.0'); + expect(spy).toHaveBeenCalledWith('my-meter', '2.0.0'); + }); +}); + +describe('createTaskCounter', () => { + it('returns a counter from the default meter', () => { + const counter = createTaskCounter(); + expect(counter).toBeDefined(); + expect(typeof counter.add).toBe('function'); + }); + + it('uses a provided meter instead of the default', () => { + const addSpy = vi.fn(); + const customMeter: TelemetryMeter = { + createCounter() { + return { add: addSpy }; + }, + createHistogram() { + return { record() {} }; + }, + createGauge() { + return { record() {} }; + }, + }; + + const counter = createTaskCounter(customMeter); + counter.add(1); + + expect(addSpy).toHaveBeenCalledWith(1); + }); + + it('counter.add does not throw', () => { + const counter = createTaskCounter(); + expect(() => counter.add(1)).not.toThrow(); + }); +}); + +describe('createTaskDurationHistogram', () => { + it('returns a histogram from the default meter', () => { + const histogram = createTaskDurationHistogram(); + expect(histogram).toBeDefined(); + expect(typeof histogram.record).toBe('function'); + }); + + it('uses a provided meter instead of the default', () => { + const recordSpy = vi.fn(); + const customMeter: TelemetryMeter = { + createCounter() { + return { add() {} }; + }, + createHistogram() { + return { record: recordSpy }; + }, + createGauge() { + return { record() {} }; + }, + }; + + const histogram = createTaskDurationHistogram(customMeter); + histogram.record(100); + + expect(recordSpy).toHaveBeenCalledWith(100); + }); + + it('histogram.record does not throw', () => { + const histogram = createTaskDurationHistogram(); + expect(() => histogram.record(100)).not.toThrow(); + }); +}); + +describe('withTaskSpan', () => { + it('starts and ends a span around the callback', async () => { + const endSpy = vi.fn(); + const startActiveSpanSpy = vi.fn( + async (_name: string, _options: unknown, fn: (span: TelemetrySpan) => Promise) => { + const span = new NoopSpan(); + span.end = endSpy; + const result = await fn(span); + span.end(); + return result; + }, + ); + const tracer: TelemetryTracer = { + startSpan() { + return new NoopSpan(); + }, + startActiveSpan: startActiveSpanSpy as unknown as TelemetryTracer['startActiveSpan'], + }; + + const result = await withTaskSpan(tracer, 'task-1', 'process', async (span) => { + return span.isRecording(); + }); + + expect(result).toBe(false); + expect(endSpy).toHaveBeenCalledOnce(); + }); + + it('includes task attributes in the span options', async () => { + const startActiveSpanSpy = vi.fn( + async (_name: string, _options: unknown, fn: (span: TelemetrySpan) => Promise) => { + return fn(new NoopSpan()); + }, + ); + const tracer: TelemetryTracer = { + startSpan() { + return new NoopSpan(); + }, + startActiveSpan: startActiveSpanSpy as unknown as TelemetryTracer['startActiveSpan'], + }; + + await withTaskSpan(tracer, 'task-42', 'compute', async () => 'done'); + + expect(startActiveSpanSpy).toHaveBeenCalledWith( + 'a2a.task.compute', + { + attributes: { + 'a2a.task.id': 'task-42', + 'a2a.operation': 'compute', + }, + kind: 'internal', + }, + expect.any(Function), + ); + }); +}); diff --git a/packages/observability/src/telemetry.ts b/packages/observability/src/telemetry.ts new file mode 100644 index 0000000..1572b24 --- /dev/null +++ b/packages/observability/src/telemetry.ts @@ -0,0 +1,221 @@ +export interface TelemetrySpan { + end(): void; + setAttribute(key: string, value: string | number | boolean): void; + setAttributes(attrs: Record): void; + addEvent(name: string, attrs?: Record): void; + setStatus(status: { code: 'ok' | 'error'; message?: string }): void; + isRecording(): boolean; +} + +export interface TelemetryTracer { + startSpan( + name: string, + options?: { + attributes?: Record; + kind?: string; + }, + ): TelemetrySpan; + startActiveSpan Promise>( + name: string, + fn: F, + ): Promise>; + startActiveSpan Promise>( + name: string, + options: { + attributes?: Record; + kind?: string; + }, + fn: F, + ): Promise>; +} + +export interface TelemetryMeter { + createCounter(name: string, options?: { description?: string; unit?: string }): TelemetryCounter; + createHistogram( + name: string, + options?: { description?: string; unit?: string }, + ): TelemetryHistogram; + createGauge(name: string, options?: { description?: string; unit?: string }): TelemetryGauge; +} + +export interface TelemetryCounter { + add(value: number, attrs?: Record): void; +} + +export interface TelemetryHistogram { + record(value: number, attrs?: Record): void; +} + +export interface TelemetryGauge { + record(value: number, attrs?: Record): void; +} + +export interface TelemetryProvider { + getTracer(name?: string, version?: string): TelemetryTracer; + getMeter(name?: string, version?: string): TelemetryMeter; + shutdown?(): Promise; +} + +export class NoopSpan implements TelemetrySpan { + end(): void {} + setAttribute(_key: string, _value: string | number | boolean): void {} + setAttributes(_attrs: Record): void {} + addEvent(_name: string, _attrs?: Record): void {} + setStatus(_status: { code: 'ok' | 'error'; message?: string }): void {} + isRecording(): boolean { + return false; + } +} + +class NoopTracer implements TelemetryTracer { + private noopSpan = new NoopSpan(); + + startSpan( + _name: string, + _options?: { + attributes?: Record; + kind?: string; + }, + ): TelemetrySpan { + return this.noopSpan; + } + + async startActiveSpan Promise>( + _name: string, + _optionsOrFn: + | { + attributes?: Record; + kind?: string; + } + | F, + _fn?: F, + ): Promise> { + const fn = typeof _optionsOrFn === 'function' ? _optionsOrFn : (_fn as F); + const options = typeof _optionsOrFn === 'function' ? {} : _optionsOrFn; + const span = this.startSpan(_name, options); + try { + const result = await fn(span); + return result as ReturnType; + } finally { + span.end(); + } + } +} + +class NoopCounter implements TelemetryCounter { + add(_value: number, _attrs?: Record): void {} +} + +class NoopHistogram implements TelemetryHistogram { + record(_value: number, _attrs?: Record): void {} +} + +class NoopGauge implements TelemetryGauge { + record(_value: number, _attrs?: Record): void {} +} + +class NoopMeter implements TelemetryMeter { + private noopCounter = new NoopCounter(); + private noopHistogram = new NoopHistogram(); + private noopGauge = new NoopGauge(); + + createCounter( + _name: string, + _options?: { description?: string; unit?: string }, + ): TelemetryCounter { + return this.noopCounter; + } + createHistogram( + _name: string, + _options?: { description?: string; unit?: string }, + ): TelemetryHistogram { + return this.noopHistogram; + } + createGauge(_name: string, _options?: { description?: string; unit?: string }): TelemetryGauge { + return this.noopGauge; + } +} + +class NoopProvider implements TelemetryProvider { + private noopTracer = new NoopTracer(); + private noopMeter = new NoopMeter(); + + getTracer(_name?: string, _version?: string): TelemetryTracer { + return this.noopTracer; + } + getMeter(_name?: string, _version?: string): TelemetryMeter { + return this.noopMeter; + } +} + +/** + * Global mutable state holding the active {@link TelemetryProvider}. + * This is an intentional pattern matching the OpenTelemetry SDK's + * `trace.setGlobalTracerProvider()` / `metrics.setGlobalMeterProvider()`. + */ +let globalProvider: TelemetryProvider = new NoopProvider(); + +let isShuttingDown = false; + +export function setTelemetryProvider(provider: TelemetryProvider): void { + const previousProvider = globalProvider; + if (previousProvider && !isShuttingDown) { + isShuttingDown = true; + previousProvider + .shutdown?.() + ?.catch((_err) => { + // intentionally fire-and-forget + }) + .finally(() => { + isShuttingDown = false; + }); + } + globalProvider = provider; +} + +export function getTelemetryProvider(): TelemetryProvider { + return globalProvider; +} + +export function getTracer(name?: string, version?: string): TelemetryTracer { + return globalProvider.getTracer(name, version); +} + +export function getMeter(name?: string, version?: string): TelemetryMeter { + return globalProvider.getMeter(name, version); +} + +export function createTaskCounter(meter?: TelemetryMeter): TelemetryCounter { + const m = meter ?? getMeter('a2a-reference'); + return m.createCounter('a2a.tasks.total', { + description: 'Total number of A2A tasks processed', + unit: '1', + }); +} + +export function createTaskDurationHistogram(meter?: TelemetryMeter): TelemetryHistogram { + const m = meter ?? getMeter('a2a-reference'); + return m.createHistogram('a2a.tasks.duration', { + description: 'Duration of A2A task execution', + unit: 'ms', + }); +} + +export async function withTaskSpan( + tracer: TelemetryTracer, + taskId: string, + operation: string, + fn: (span: TelemetrySpan) => Promise, +): Promise { + return tracer.startActiveSpan( + `a2a.task.${operation}`, + { + attributes: { + 'a2a.task.id': taskId, + 'a2a.operation': operation, + }, + kind: 'internal', + }, + fn, + ); +} diff --git a/packages/persistence/README.md b/packages/persistence/README.md index 106a039..4c4fadc 100644 --- a/packages/persistence/README.md +++ b/packages/persistence/README.md @@ -1,198 +1,56 @@ # @reaatech/a2a-reference-persistence -[![npm version](https://img.shields.io/npm/v/@reaatech/a2a-reference-persistence.svg)](https://www.npmjs.com/package/@reaatech/a2a-reference-persistence) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/reaatech/a2a-reference-ts/blob/main/LICENSE) -[![CI](https://img.shields.io/github/actions/workflow/status/reaatech/a2a-reference-ts/ci.yml?branch=main&label=CI)](https://github.com/reaatech/a2a-reference-ts/actions/workflows/ci.yml) +Task store abstractions for A2A task persistence. -> **Status:** Pre-1.0 — APIs may change in minor versions. Pin to a specific version in production. +## Stores -Task store abstractions for persisting A2A task state. Provides a consistent `TaskStore` interface with three implementations: in-memory (zero-dependency), file-system (single JSON file), and Redis (via ioredis). - -## Installation - -```bash -npm install @reaatech/a2a-reference-persistence -# or -pnpm add @reaatech/a2a-reference-persistence +### InMemoryTaskStore +Fast, ephemeral storage for development and testing: +```ts +const store = new InMemoryTaskStore(); ``` -For Redis support, install `ioredis`: - -```bash -npm install ioredis +### FileSystemTaskStore +JSON file-based persistence with periodic flush: +```ts +const store = new FileSystemTaskStore({ path: './data/tasks.json' }); +await store.load(); ``` -## Feature Overview - -- **Single abstraction** — `TaskStore` interface defines 8 methods shared by all implementations -- **In-memory store** — fast, ephemeral, zero dependencies beyond `@reaatech/a2a-reference-core` -- **File-system store** — persists to a single JSON file, write-through on every mutation -- **Redis store** — distributed, persistent, suitable for multi-process deployments -- **History truncation** — automatic enforcement of `historyLength` caps -- **Paginated listing** — cursor-based pagination with `contextId` and `status` filtering - -## Quick Start - -```typescript -import { - InMemoryTaskStore, - FileSystemTaskStore, - RedisTaskStore, -} from "@reaatech/a2a-reference-persistence"; -import Redis from "ioredis"; - -// In-memory (ephemeral, no setup) -const memoryStore = new InMemoryTaskStore(); +### RedisTaskStore +Production-grade storage with Redis: +```ts +const store = new RedisTaskStore({ redis: new Redis() }); +``` -// File-system (persistent, single JSON file) -const fileStore = new FileSystemTaskStore({ path: "./tasks.json" }); -await fileStore.load(); +### PostgresTaskStore +Relational storage for audit-heavy deployments: +```ts +import pg from 'pg'; -// Redis (distributed, production) -const redis = new Redis("redis://localhost:6379"); -const redisStore = new RedisTaskStore({ redis }); +const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); +const store = new PostgresTaskStore({ pool }); +await store.initialize(); ``` -## API Reference +PostgresTaskStore auto-creates the following tables: +- `a2a_tasks` — Task metadata and status +- `a2a_history` — Message history (cascading delete) +- `a2a_artifacts` — Artifact storage with ordering -### `TaskStore` Interface +## Common Interface -The contract that all implementations fulfill: +All stores implement `TaskStore`: -```typescript +```ts interface TaskStore { create(task: Task): Promise; get(id: string, options?: { historyLength?: number }): Promise; - update(id: string, updates: Partial | ((task: Task) => Task)): Promise; - list(options?: { - contextId?: string; - status?: TaskStatus; - pageSize?: number; - pageToken?: string; - historyLength?: number; - }): Promise<{ tasks: Task[]; nextPageToken: string; totalSize: number }>; + update(id: string, updates: Partial | ((t: Task) => Task)): Promise; + list(options?: { contextId?, status?, pageSize?, pageToken?, historyLength? }): Promise<...>; cancel(id: string): Promise; addHistory(id: string, message: Message): Promise; addArtifact(id: string, artifact: Artifact): Promise; updateStatus(id: string, status: TaskStatus): Promise; } ``` - -| Method | Description | -|--------|-------------| -| `create` | Persist a new task | -| `get` | Retrieve a task by ID with optional history truncation | -| `update` | Partial update or function-based update | -| `list` | Paginated listing with optional filters | -| `cancel` | Cancel a task (no-op if already terminal) | -| `addHistory` | Append a message to the task's history | -| `addArtifact` | Append an artifact to the task | -| `updateStatus` | Replace the task's status object | - -### `InMemoryTaskStore` - -Ephemeral `Map`-backed store. Ideal for development and testing. - -```typescript -const store = new InMemoryTaskStore(); - -await store.create(task); -const t = await store.get("task-abc123"); -const { tasks, nextPageToken, totalSize } = await store.list({ pageSize: 10 }); -``` - -- No constructor arguments -- Data is lost on process exit -- No cleanup or connection management required - -### `FileSystemTaskStore` - -Persists tasks to a single JSON file with write-through on every mutation. - -```typescript -const store = new FileSystemTaskStore({ path: "./data/tasks.json" }); -await store.load(); // Hydrate from disk (idempotent — creates file if missing) -// ... use the store ... -await store.close(); // Flush and stop periodic sync -``` - -#### `FileSystemTaskStoreOptions` - -| Property | Type | Description | -|----------|------|-------------| -| `path` | `string` | File path to the JSON store file | - -Key behaviors: -- **Write-through** — every mutation immediately writes the full JSON file -- **Periodic flush** — a 5-second interval ensures data is synced even if a write is missed -- **Error tolerance** — missing files on `load()` initialize an empty store without error -- **Shutdown** — call `close()` before process exit to flush pending writes - -### `RedisTaskStore` - -Redis-backed store using [ioredis](https://github.com/redis/ioredis). Suitable for distributed, multi-process deployments. - -```typescript -import Redis from "ioredis"; - -const redis = new Redis({ - host: "localhost", - port: 6379, - password: "optional", -}); - -const store = new RedisTaskStore({ redis, keyPrefix: "a2a" }); -// ... use the store ... -await store.close(); // Calls redis.quit() -``` - -#### `RedisTaskStoreOptions` - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `redis` | `Redis` | (required) | Pre-configured ioredis client instance | -| `keyPrefix` | `string` | `"a2a"` | Prefix for all Redis keys | - -Key schema: -- `:task:` — JSON-serialized task objects -- `:tasks` — Redis set tracking all task IDs for enumeration - -## Integration with the Server - -```typescript -import { createA2AExpressApp } from "@reaatech/a2a-reference-server"; -import { FileSystemTaskStore } from "@reaatech/a2a-reference-persistence"; - -const taskStore = new FileSystemTaskStore({ path: "./tasks.json" }); -await taskStore.load(); - -const app = createA2AExpressApp({ agentCard, executor, taskStore }); - -// Graceful shutdown -process.on("SIGTERM", async () => { - await app.shutdown(); - await taskStore.close(); - process.exit(0); -}); - -app.listen(3000); -``` - -## Choosing a Store - -| Store | Use Case | -|-------|----------| -| `InMemoryTaskStore` | Development, testing, single-process prototypes | -| `FileSystemTaskStore` | Single-server deployments, simple persistence needs | -| `RedisTaskStore` | Multi-process deployments, horizontal scaling, production | - -All three implement the same interface — swap them without changing application code. - -## Related Packages - -- [`@reaatech/a2a-reference-core`](https://www.npmjs.com/package/@reaatech/a2a-reference-core) — Protocol types (`Task`, `Message`, `Artifact`, `TaskStatus`) -- [`@reaatech/a2a-reference-server`](https://www.npmjs.com/package/@reaatech/a2a-reference-server) — Server framework that consumes `TaskStore` - -## License - -[MIT](https://github.com/reaatech/a2a-reference-ts/blob/main/LICENSE) diff --git a/packages/persistence/package.json b/packages/persistence/package.json index 132aaaa..959d9ab 100644 --- a/packages/persistence/package.json +++ b/packages/persistence/package.json @@ -36,10 +36,20 @@ }, "dependencies": { "@reaatech/a2a-reference-core": "workspace:*", + "@reaatech/a2a-reference-observability": "workspace:*", "ioredis": "^5.10.1" }, + "peerDependencies": { + "pg": "^8.14.1" + }, + "peerDependenciesMeta": { + "pg": { + "optional": true + } + }, "devDependencies": { "@types/node": "^25.6.0", + "@types/pg": "^8.11.11", "tsup": "^8.4.0", "typescript": "^5.8.3", "vitest": "^3.1.1" diff --git a/packages/persistence/src/file-system.ts b/packages/persistence/src/file-system.ts index 1f7d51c..37c4c82 100644 --- a/packages/persistence/src/file-system.ts +++ b/packages/persistence/src/file-system.ts @@ -96,6 +96,7 @@ export class FileSystemTaskStore implements TaskStore { async list(options?: { contextId?: string; status?: string; + principal?: string; pageSize?: number; pageToken?: string; historyLength?: number; @@ -107,6 +108,9 @@ export class FileSystemTaskStore implements TaskStore { if (options?.status) { tasks = tasks.filter((t) => t.status.state === options.status); } + if (options?.principal) { + tasks = tasks.filter((t) => t.principal === options.principal); + } tasks.sort((a, b) => { const aTime = a.status.timestamp ?? ''; const bTime = b.status.timestamp ?? ''; diff --git a/packages/persistence/src/in-memory.ts b/packages/persistence/src/in-memory.ts index 5fb979e..e4165e9 100644 --- a/packages/persistence/src/in-memory.ts +++ b/packages/persistence/src/in-memory.ts @@ -29,6 +29,7 @@ export class InMemoryTaskStore implements TaskStore { async list(options?: { contextId?: string; status?: string; + principal?: string; pageSize?: number; pageToken?: string; historyLength?: number; @@ -40,6 +41,9 @@ export class InMemoryTaskStore implements TaskStore { if (options?.status) { tasks = tasks.filter((t) => t.status.state === options.status); } + if (options?.principal) { + tasks = tasks.filter((t) => t.principal === options.principal); + } tasks.sort((a, b) => { const aTime = a.status.timestamp ?? ''; const bTime = b.status.timestamp ?? ''; diff --git a/packages/persistence/src/index.ts b/packages/persistence/src/index.ts index 6e29c77..716cdf2 100644 --- a/packages/persistence/src/index.ts +++ b/packages/persistence/src/index.ts @@ -4,3 +4,5 @@ export { FileSystemTaskStore } from './file-system.js'; export type { FileSystemTaskStoreOptions } from './file-system.js'; export { RedisTaskStore } from './redis.js'; export type { RedisTaskStoreOptions } from './redis.js'; +export { PostgresTaskStore } from './postgres.js'; +export type { PostgresTaskStoreOptions } from './postgres.js'; diff --git a/packages/persistence/src/postgres.test.ts b/packages/persistence/src/postgres.test.ts new file mode 100644 index 0000000..151ea28 --- /dev/null +++ b/packages/persistence/src/postgres.test.ts @@ -0,0 +1,760 @@ +import type { Task } from '@reaatech/a2a-reference-core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PostgresTaskStore } from './postgres.js'; + +vi.mock('pg', () => ({ Pool: vi.fn() })); + +type MockClient = { query: ReturnType; release: ReturnType }; +type MockPool = { + connect: ReturnType; + query: ReturnType; + end: ReturnType; +}; + +let mockClient: MockClient; +let mockPool: MockPool; + +beforeEach(() => { + mockClient = { query: vi.fn().mockResolvedValue({ rows: [] }), release: vi.fn() }; + mockPool = { + connect: vi.fn().mockResolvedValue(mockClient), + query: vi.fn().mockResolvedValue({ rows: [] }), + end: vi.fn(), + }; +}); + +function createStore() { + return new PostgresTaskStore({ pool: mockPool as any }); +} + +function taskRow(overrides: Record = {}) { + return { + id: 'task-1', + context_id: 'ctx-1', + status_state: 'submitted', + status_timestamp: '2025-01-01T00:00:00.000Z', + status_message: null, + metadata: null, + principal: 'user-1', + tenant_id: 'tenant-1', + history_length: null, + created_at: '2025-01-01T00:00:00.000Z', + updated_at: '2025-01-01T00:00:00.000Z', + ...overrides, + }; +} + +function historyRow(overrides: Record = {}) { + return { + id: 1, + task_id: 'task-1', + role: 'user', + parts: JSON.stringify([{ kind: 'text', text: 'hello' }]), + message_id: 'msg-1', + metadata: null, + created_at: '2025-01-01T00:00:00.000Z', + ...overrides, + }; +} + +function artifactRow(overrides: Record = {}) { + return { + id: 1, + task_id: 'task-1', + name: 'result', + description: 'The result', + parts: JSON.stringify([{ kind: 'text', text: 'output' }]), + metadata: null, + index: 0, + append: false, + last_chunk: true, + created_at: '2025-01-01T00:00:00.000Z', + ...overrides, + }; +} + +const fullTask: Task = { + id: 'task-1', + contextId: 'ctx-1', + status: { state: 'submitted', timestamp: '2025-01-01T00:00:00.000Z' }, + principal: 'user-1', + tenantId: 'tenant-1', +}; + +// ─── Constructor & Initialization ─────────────────────────────────────────── + +describe('constructor', () => { + it('escapes schema name correctly', () => { + const s = new PostgresTaskStore({ pool: mockPool as any, schemaName: 'test"schema' }); + expect((s as any).taskTable).toContain('"test""schema"'); + expect((s as any).artifactTable).toContain('"test""schema"'); + expect((s as any).historyTable).toContain('"test""schema"'); + }); + + it('uses default table prefix when not provided', () => { + const s = new PostgresTaskStore({ pool: mockPool as any }); + expect((s as any).tablePrefix).toBe('a2a'); + expect((s as any).taskTable).toContain('"a2a_tasks"'); + expect((s as any).artifactTable).toContain('"a2a_artifacts"'); + expect((s as any).historyTable).toContain('"a2a_history"'); + }); +}); + +describe('initialize', () => { + it('creates tables and indexes', async () => { + mockClient.query.mockResolvedValue({ rows: [] }); + await createStore().initialize(); + expect(mockClient.query).toHaveBeenCalledTimes(9); + expect(mockClient.release).toHaveBeenCalledTimes(1); + }); +}); + +// ─── create() ──────────────────────────────────────────────────────────────── + +describe('create', () => { + it('inserts task row with all fields', async () => { + mockClient.query + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [] }) // SELECT check + .mockResolvedValueOnce({ rows: [] }) // INSERT task + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + await createStore().create(fullTask); + + const calls = mockClient.query.mock.calls; + expect(calls[0][0]).toBe('BEGIN'); + expect(calls[3][0]).toBe('COMMIT'); + const insertSql = calls[2][0] as string; + expect(insertSql).toContain('INSERT INTO'); + expect(insertSql).toContain('a2a_tasks'); + expect(calls[2][1]).toEqual([ + 'task-1', + 'ctx-1', + 'submitted', + '2025-01-01T00:00:00.000Z', + null, + null, + 'user-1', + 'tenant-1', + null, + ]); + }); + + it('inserts history rows when task has history', async () => { + mockClient.query + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [] }) // SELECT check + .mockResolvedValueOnce({ rows: [] }) // INSERT task + .mockResolvedValueOnce({ rows: [] }) // INSERT history 1 + .mockResolvedValueOnce({ rows: [] }) // INSERT history 2 + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + await createStore().create({ + ...fullTask, + history: [ + { role: 'user', parts: [{ kind: 'text', text: 'hi' }], messageId: 'msg-1' }, + { role: 'agent', parts: [{ kind: 'text', text: 'hello' }], messageId: 'msg-2' }, + ], + }); + + expect(mockClient.query).toHaveBeenCalledTimes(6); + const histInserts = [mockClient.query.mock.calls[3], mockClient.query.mock.calls[4]]; + expect(histInserts[0][1]).toEqual([ + 'task-1', + 'user', + JSON.stringify([{ kind: 'text', text: 'hi' }]), + 'msg-1', + null, + ]); + expect(histInserts[1][1]).toEqual([ + 'task-1', + 'agent', + JSON.stringify([{ kind: 'text', text: 'hello' }]), + 'msg-2', + null, + ]); + }); + + it('inserts artifact rows when task has artifacts', async () => { + mockClient.query + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [] }) // SELECT check + .mockResolvedValueOnce({ rows: [] }) // INSERT task + .mockResolvedValueOnce({ rows: [] }) // INSERT artifact 1 + .mockResolvedValueOnce({ rows: [] }) // INSERT artifact 2 + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + await createStore().create({ + ...fullTask, + artifacts: [ + { name: 'a1', description: 'd1', parts: [{ kind: 'text', text: 'x' }] }, + { name: 'a2', parts: [{ kind: 'text', text: 'y' }] }, + ], + }); + + expect(mockClient.query).toHaveBeenCalledTimes(6); + const artInserts = [mockClient.query.mock.calls[3], mockClient.query.mock.calls[4]]; + expect(artInserts[0][1]).toContain('a1'); + expect(artInserts[0][1]).toContain(0); // index + expect(artInserts[1][1]).toContain('a2'); + expect(artInserts[1][1]).toContain(1); // index + }); + + it('throws if task already exists', async () => { + mockClient.query + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ id: 'task-1' }] }); // SELECT check + + await expect(createStore().create(fullTask)).rejects.toThrow('Task already exists: task-1'); + }); + + it('rolls back on query error', async () => { + mockClient.query + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [] }) // SELECT check + .mockRejectedValueOnce(new Error('boom')); // INSERT fails + + await expect(createStore().create(fullTask)).rejects.toThrow('boom'); + + const rollbackCall = mockClient.query.mock.calls.find((c: unknown[]) => c[0] === 'ROLLBACK'); + expect(rollbackCall).toBeDefined(); + }); +}); + +// ─── get() ─────────────────────────────────────────────────────────────────── + +describe('get', () => { + it('returns undefined for missing task', async () => { + const result = await createStore().get('missing'); + expect(result).toBeUndefined(); + }); + + it('returns full task with history and artifacts', async () => { + mockClient.query + .mockResolvedValueOnce({ rows: [taskRow()] }) // SELECT task + .mockResolvedValueOnce({ rows: [historyRow()] }) // SELECT history + .mockResolvedValueOnce({ rows: [artifactRow()] }); // SELECT artifacts + + const result = await createStore().get('task-1'); + + expect(result).toBeDefined(); + expect(result?.id).toBe('task-1'); + expect(result?.contextId).toBe('ctx-1'); + expect(result?.status.state).toBe('submitted'); + expect(result?.principal).toBe('user-1'); + expect(result?.tenantId).toBe('tenant-1'); + expect(result?.history).toHaveLength(1); + expect(result?.history?.[0].role).toBe('user'); + expect(result?.history?.[0].messageId).toBe('msg-1'); + expect(result?.artifacts).toHaveLength(1); + expect(result?.artifacts?.[0].name).toBe('result'); + }); + + it('applies historyLength option', async () => { + mockClient.query + .mockResolvedValueOnce({ rows: [taskRow()] }) // SELECT task + .mockResolvedValueOnce({ + // SELECT history — 3 rows + rows: [ + historyRow({ id: 1, message_id: 'msg-1' }), + historyRow({ id: 2, message_id: 'msg-2', role: 'agent' }), + historyRow({ id: 3, message_id: 'msg-3' }), + ], + }) + .mockResolvedValueOnce({ rows: [] }); // SELECT artifacts + + const result = await createStore().get('task-1', { historyLength: 2 }); + + expect(result?.history).toHaveLength(2); + expect(result?.history?.[0].messageId).toBe('msg-2'); + expect(result?.history?.[1].messageId).toBe('msg-3'); + }); + + it('handles nullable and optional fields', async () => { + mockClient.query + .mockResolvedValueOnce({ + rows: [ + taskRow({ + context_id: null, + status_message: JSON.stringify({ + role: 'user', + parts: [{ kind: 'text', text: 'msg' }], + }), + metadata: JSON.stringify({ foo: 'bar' }), + principal: null, + tenant_id: null, + }), + ], + }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }); + + const result = await createStore().get('task-1'); + + expect(result?.contextId).toBeUndefined(); + expect(result?.principal).toBeUndefined(); + expect(result?.tenantId).toBeUndefined(); + expect(result?.status.message).toBeDefined(); + expect((result?.status.message as any).role).toBe('user'); + expect(result?.metadata).toEqual({ foo: 'bar' }); + }); + + it('falls back to generated messageId when history has none', async () => { + mockClient.query + .mockResolvedValueOnce({ rows: [taskRow()] }) + .mockResolvedValueOnce({ rows: [historyRow({ message_id: null })] }) + .mockResolvedValueOnce({ rows: [] }); + + const result = await createStore().get('task-1'); + expect(result?.history?.[0].messageId).toMatch(/^msg-/); + }); +}); + +// ─── update() ──────────────────────────────────────────────────────────────── + +describe('update', () => { + it('updates task fields', async () => { + mockClient.query + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [taskRow()] }) // SELECT ... FOR UPDATE + .mockResolvedValueOnce({ rows: [historyRow()] }) // rowToTask: SELECT history + .mockResolvedValueOnce({ rows: [artifactRow()] }); // rowToTask: SELECT artifacts + // UPDATE, history/artifact replacement, and COMMIT fall through to default { rows: [] } + + const result = await createStore().update('task-1', { + principal: 'user-2', + }); + + expect(result).toBeDefined(); + expect(result?.principal).toBe('user-2'); + expect(result?.id).toBe('task-1'); + + const updateCall = mockClient.query.mock.calls.find( + (c) => typeof c[0] === 'string' && c[0].includes('UPDATE') && c[0].includes('SET'), + ); + expect(updateCall).toBeDefined(); + expect(updateCall?.[0] as string).toContain('a2a_tasks'); + expect(updateCall?.[1][6]).toBe('user-2'); + }); + + it('returns undefined for missing task', async () => { + // BEGIN then an empty SELECT ... FOR UPDATE → ROLLBACK, undefined + mockClient.query.mockResolvedValue({ rows: [] }); + + const result = await createStore().update('missing', { + status: { state: 'completed' }, + }); + expect(result).toBeUndefined(); + }); + + it('works with function updater', async () => { + mockClient.query + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [taskRow()] }) // SELECT ... FOR UPDATE + .mockResolvedValueOnce({ rows: [historyRow()] }) // rowToTask: SELECT history + .mockResolvedValueOnce({ rows: [artifactRow()] }); // rowToTask: SELECT artifacts + + const result = await createStore().update('task-1', (t: Task) => ({ + ...t, + status: { ...t.status, state: 'working' as const }, + })); + + expect(result).toBeDefined(); + expect(result?.status.state).toBe('working'); + }); +}); + +// ─── list() ────────────────────────────────────────────────────────────────── + +describe('list', () => { + it('returns all tasks', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: '2' }] }) // COUNT + .mockResolvedValueOnce({ rows: [taskRow({ id: 't1' }), taskRow({ id: 't2' })] }); // SELECT + + mockClient.query + .mockResolvedValueOnce({ rows: [] }) // history for t1 + .mockResolvedValueOnce({ rows: [] }) // artifacts for t1 + .mockResolvedValueOnce({ rows: [] }) // history for t2 + .mockResolvedValueOnce({ rows: [] }); // artifacts for t2 + + const result = await createStore().list(); + + expect(result.tasks).toHaveLength(2); + expect(result.totalSize).toBe(2); + expect(result.nextPageToken).toBe(''); + }); + + it('returns empty list when no tasks', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: '0' }] }) + .mockResolvedValueOnce({ rows: [] }); + + const result = await createStore().list(); + expect(result.tasks).toHaveLength(0); + expect(result.totalSize).toBe(0); + expect(result.nextPageToken).toBe(''); + }); + + it('filters by contextId', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: '1' }] }) + .mockResolvedValueOnce({ rows: [taskRow({ id: 't1', context_id: 'ctx-1' })] }); + + mockClient.query.mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows: [] }); + + const result = await createStore().list({ contextId: 'ctx-1' }); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].id).toBe('t1'); + const countSql = mockPool.query.mock.calls[0][0] as string; + expect(countSql).toContain('WHERE'); + expect(countSql).toContain('context_id'); + }); + + it('filters by status', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: '1' }] }) + .mockResolvedValueOnce({ rows: [taskRow({ id: 't1', status_state: 'completed' })] }); + + mockClient.query.mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows: [] }); + + const result = await createStore().list({ status: 'completed' }); + + expect(result.tasks).toHaveLength(1); + expect(result.tasks[0].status.state).toBe('completed'); + }); + + it('paginates correctly', async () => { + const manyRows = Array.from({ length: 5 }, (_, i) => taskRow({ id: `t${i + 1}` })); + + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: '5' }] }) // COUNT + .mockResolvedValueOnce({ rows: manyRows.slice(0, 2) }); // SELECT LIMIT 2 + + // 2 tasks * 2 queries each = 4 + for (let i = 0; i < 2; i++) { + mockClient.query.mockResolvedValueOnce({ rows: [] }); // history + mockClient.query.mockResolvedValueOnce({ rows: [] }); // artifacts + } + + const result = await createStore().list({ pageSize: 2, pageToken: '0' }); + + expect(result.tasks).toHaveLength(2); + expect(result.totalSize).toBe(5); + expect(result.nextPageToken).toBe('1'); + }); + + it('returns empty nextPageToken on last page', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: '2' }] }) + .mockResolvedValueOnce({ rows: [taskRow({ id: 't1' }), taskRow({ id: 't2' })] }); + + mockClient.query + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }); + + const result = await createStore().list({ pageSize: 5, pageToken: '0' }); + + expect(result.nextPageToken).toBe(''); + }); +}); + +// ─── cancel() ──────────────────────────────────────────────────────────────── + +describe('cancel', () => { + it('cancels a task in non-terminal state', async () => { + mockClient.query + .mockResolvedValueOnce({ rows: [taskRow({ status_state: 'working' })] }) // SELECT + .mockResolvedValueOnce({ rows: [] }) // UPDATE to canceled + // internal get() after cancel + .mockResolvedValueOnce({ rows: [taskRow({ status_state: 'canceled' })] }) // SELECT task + .mockResolvedValueOnce({ rows: [] }) // SELECT history + .mockResolvedValueOnce({ rows: [] }); // SELECT artifacts + + const result = await createStore().cancel('task-1'); + + expect(result).toBeDefined(); + expect(result?.status.state).toBe('canceled'); + const updateCall = mockClient.query.mock.calls[1]; + expect(updateCall[0] as string).toContain('UPDATE'); + expect(updateCall[0] as string).toContain("status_state = 'canceled'"); + }); + + it('returns undefined for terminal state', async () => { + mockClient.query.mockResolvedValueOnce({ rows: [taskRow({ status_state: 'completed' })] }); // SELECT + + const result = await createStore().cancel('task-1'); + expect(result).toBeUndefined(); + }); + + it('returns undefined for missing task', async () => { + mockClient.query.mockResolvedValueOnce({ rows: [] }); // SELECT — empty + + const result = await createStore().cancel('missing'); + expect(result).toBeUndefined(); + }); +}); + +// ─── addHistory() ──────────────────────────────────────────────────────────── + +describe('addHistory', () => { + it('adds history message', async () => { + mockClient.query + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ history_length: null }] }) // SELECT FOR UPDATE + .mockResolvedValueOnce({ rows: [] }) // INSERT history + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + await createStore().addHistory('task-1', { + messageId: 'msg-10', + role: 'user', + parts: [{ kind: 'text', text: 'hello' }], + }); + + expect(mockClient.query).toHaveBeenCalledTimes(4); + const insertCall = mockClient.query.mock.calls[2]; + expect(insertCall[0] as string).toContain('INSERT INTO'); + expect(insertCall[0] as string).toContain('a2a_history'); + expect(insertCall[1][1]).toBe('user'); + expect(insertCall[1][2]).toBe(JSON.stringify([{ kind: 'text', text: 'hello' }])); + }); + + it('throws if task not found', async () => { + mockClient.query + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [] }); // SELECT FOR UPDATE — empty + + await expect( + createStore().addHistory('missing', { + messageId: 'msg-11', + role: 'user', + parts: [{ kind: 'text', text: 'x' }], + }), + ).rejects.toThrow('Task not found: missing'); + }); + + it('trims history to historyLength', async () => { + mockClient.query + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ history_length: 2 }] }) // SELECT FOR UPDATE + .mockResolvedValueOnce({ rows: [] }) // INSERT history + .mockResolvedValueOnce({ rows: [] }) // DELETE old history + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + await createStore().addHistory('task-1', { + messageId: 'msg-12', + role: 'agent', + parts: [{ kind: 'text', text: 'response' }], + }); + + expect(mockClient.query).toHaveBeenCalledTimes(5); + const deleteCall = mockClient.query.mock.calls[3]; + expect(deleteCall[0] as string).toContain('DELETE'); + expect(deleteCall[1]).toEqual(['task-1', 2]); + }); + + it('trims with history_length 0 (deletes all)', async () => { + mockClient.query + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ history_length: 0 }] }) // SELECT FOR UPDATE + .mockResolvedValueOnce({ rows: [] }) // INSERT history + .mockResolvedValueOnce({ rows: [] }) // DELETE with LIMIT 0 → deletes all + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + await createStore().addHistory('task-1', { + messageId: 'msg-13', + role: 'user', + parts: [{ kind: 'text', text: 'x' }], + }); + + expect(mockClient.query).toHaveBeenCalledTimes(5); + const deleteCall = mockClient.query.mock.calls[3]; + expect(deleteCall[1]).toEqual(['task-1', 0]); + }); + + it('rolls back on error and rethrows', async () => { + mockClient.query + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockRejectedValueOnce(new Error('select fail')); // SELECT FOR UPDATE fails + + await expect( + createStore().addHistory('task-1', { + messageId: 'msg-14', + role: 'user', + parts: [{ kind: 'text', text: 'x' }], + }), + ).rejects.toThrow('select fail'); + + const rollbackCall = mockClient.query.mock.calls.find((c: unknown[]) => c[0] === 'ROLLBACK'); + expect(rollbackCall).toBeDefined(); + }); +}); + +// ─── addArtifact() ─────────────────────────────────────────────────────────── + +describe('addArtifact', () => { + it('adds artifact with correct index', async () => { + mockClient.query + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ next_index: 5 }] }) // SELECT MAX(index) + 1 + .mockResolvedValueOnce({ rows: [] }) // INSERT artifact + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + await createStore().addArtifact('task-1', { + name: 'result', + description: 'Final output', + parts: [{ kind: 'text', text: 'done' }], + }); + + const insertCall = mockClient.query.mock.calls[2]; + expect(insertCall[1][5]).toBe(5); // index param + expect(mockClient.query).toHaveBeenCalledTimes(4); + }); + + it('inserts with index 0 when no prior artifacts', async () => { + mockClient.query + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ next_index: 0 }] }) // SELECT MAX(index) + 1 (no rows → COALESCE gives 0) + .mockResolvedValueOnce({ rows: [] }) // INSERT + .mockResolvedValueOnce({ rows: [] }); // COMMIT + + await createStore().addArtifact('task-1', { + parts: [{ kind: 'text', text: 'first' }], + }); + + const insertCall = mockClient.query.mock.calls[2]; + expect(insertCall[1][5]).toBe(0); + }); + + it('rolls back on error', async () => { + mockClient.query + .mockResolvedValueOnce({ rows: [] }) // BEGIN + .mockResolvedValueOnce({ rows: [{ next_index: 1 }] }) // SELECT MAX + .mockRejectedValueOnce(new Error('insert fail')); // INSERT fails + + await expect( + createStore().addArtifact('task-1', { + parts: [{ kind: 'text', text: 'oops' }], + }), + ).rejects.toThrow('insert fail'); + + const rollbackCall = mockClient.query.mock.calls.find((c: unknown[]) => c[0] === 'ROLLBACK'); + expect(rollbackCall).toBeDefined(); + }); +}); + +// ─── updateStatus() ────────────────────────────────────────────────────────── + +describe('updateStatus', () => { + it('updates task status', async () => { + mockPool.query.mockResolvedValueOnce({ rowCount: 1 }); + + await createStore().updateStatus('task-1', { + state: 'working', + timestamp: '2025-06-01T00:00:00.000Z', + }); + + expect(mockPool.query).toHaveBeenCalledTimes(1); + const sql = mockPool.query.mock.calls[0][0] as string; + expect(sql).toContain('UPDATE'); + expect(sql).toContain('a2a_tasks'); + expect(sql).toContain('status_state'); + expect(mockPool.query.mock.calls[0][1]).toEqual([ + 'task-1', + 'working', + '2025-06-01T00:00:00.000Z', + null, + ]); + }); + + it('throws if task not found', async () => { + mockPool.query.mockResolvedValueOnce({ rowCount: 0 }); + + await expect(createStore().updateStatus('missing', { state: 'completed' })).rejects.toThrow( + 'Task not found: missing', + ); + }); +}); + +// ─── rowToTask (via get) ───────────────────────────────────────────────────── + +describe('rowToTask', () => { + it('parses JSONB string fields correctly', async () => { + mockClient.query + .mockResolvedValueOnce({ + rows: [ + taskRow({ + status_message: JSON.stringify({ + role: 'user', + parts: [{ kind: 'text', text: 'status msg' }], + }), + metadata: JSON.stringify({ key: 'value', nested: { a: 1 } }), + }), + ], + }) + .mockResolvedValueOnce({ + rows: [ + historyRow({ + parts: JSON.stringify([{ kind: 'text', text: 'hist' }]), + metadata: JSON.stringify({ source: 'user' }), + }), + ], + }) + .mockResolvedValueOnce({ + rows: [ + artifactRow({ + parts: JSON.stringify([{ kind: 'text', text: 'art' }]), + metadata: JSON.stringify({ version: 2 }), + }), + ], + }); + + const result = await createStore().get('task-1'); + + expect(result?.status.message).toBeDefined(); + expect((result?.status.message as any).role).toBe('user'); + expect(result?.metadata).toEqual({ key: 'value', nested: { a: 1 } }); + expect(result?.history?.[0].metadata).toEqual({ source: 'user' }); + expect(result?.artifacts?.[0].metadata).toEqual({ version: 2 }); + }); + + it('defaults invalid status state to failed', async () => { + mockClient.query + .mockResolvedValueOnce({ rows: [taskRow({ status_state: 'bogus-state' })] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }); + + const result = await createStore().get('task-1'); + expect(result?.status.state).toBe('failed'); + }); + + it('returns undefined when row has no id', async () => { + mockClient.query.mockResolvedValueOnce({ rows: [taskRow({ id: null })] }); + + const result = await createStore().get('task-1'); + expect(result).toBeUndefined(); + }); + + it('handles empty history and artifacts', async () => { + mockClient.query + .mockResolvedValueOnce({ rows: [taskRow()] }) + .mockResolvedValueOnce({ rows: [] }) + .mockResolvedValueOnce({ rows: [] }); + + const result = await createStore().get('task-1'); + expect(result?.history).toBeUndefined(); + expect(result?.artifacts).toBeUndefined(); + }); +}); + +// ─── close() ───────────────────────────────────────────────────────────────── + +describe('close', () => { + it('ends the pool', async () => { + await createStore().close(); + expect(mockPool.end).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/persistence/src/postgres.ts b/packages/persistence/src/postgres.ts new file mode 100644 index 0000000..e8fc4b6 --- /dev/null +++ b/packages/persistence/src/postgres.ts @@ -0,0 +1,602 @@ +import type { Artifact, Message, Task, TaskStatus } from '@reaatech/a2a-reference-core'; +import { A2AError, TaskNotFoundError } from '@reaatech/a2a-reference-core'; +import { defaultLogger } from '@reaatech/a2a-reference-observability'; +import type { Pool, PoolClient } from 'pg'; +import { applyHistoryLength } from './shared.js'; +import type { TaskStore } from './store.js'; + +/** + * Options for {@link PostgresTaskStore}. + * + * Both `tablePrefix` and `schemaName` are validated against SQL injection + * by escaping double-quote characters (`"` → `""`) before interpolation into + * quoted identifiers. + */ +export interface PostgresTaskStoreOptions { + pool: Pool; + tablePrefix?: string; + schemaName?: string; +} + +const DEFAULT_TABLE_PREFIX = 'a2a'; + +export class PostgresTaskStore implements TaskStore { + private pool: Pool; + private schemaName: string; + private tablePrefix: string; + private taskTable: string; + private artifactTable: string; + private historyTable: string; + + constructor(options: PostgresTaskStoreOptions) { + this.pool = options.pool; + this.schemaName = options.schemaName ?? 'public'; + this.tablePrefix = (options.tablePrefix ?? DEFAULT_TABLE_PREFIX).replace(/"/g, '""'); + const escapedSchema = this.schemaName.replace(/"/g, '""'); + this.taskTable = `"${escapedSchema}"."${this.tablePrefix}_tasks"`; + this.artifactTable = `"${escapedSchema}"."${this.tablePrefix}_artifacts"`; + this.historyTable = `"${escapedSchema}"."${this.tablePrefix}_history"`; + } + + async initialize(): Promise { + const client = await this.pool.connect(); + try { + await client.query(` + CREATE SCHEMA IF NOT EXISTS "${this.schemaName.replace(/"/g, '""')}"; + `); + + await client.query(` + CREATE TABLE IF NOT EXISTS ${this.taskTable} ( + id TEXT PRIMARY KEY, + context_id TEXT, + status_state TEXT NOT NULL, + status_timestamp TIMESTAMPTZ, + status_message JSONB, + metadata JSONB, + principal TEXT, + tenant_id TEXT, + history_length INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `); + + await client.query(` + CREATE TABLE IF NOT EXISTS ${this.historyTable} ( + id SERIAL PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES ${this.taskTable}(id) ON DELETE CASCADE, + role TEXT NOT NULL, + parts JSONB NOT NULL, + message_id TEXT, + metadata JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `); + + await client.query(` + CREATE INDEX IF NOT EXISTS "idx_${this.tablePrefix}_history_task_id" + ON ${this.historyTable} (task_id); + `); + + await client.query(` + CREATE TABLE IF NOT EXISTS ${this.artifactTable} ( + id SERIAL PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES ${this.taskTable}(id) ON DELETE CASCADE, + name TEXT, + description TEXT, + parts JSONB NOT NULL, + metadata JSONB, + index INT NOT NULL DEFAULT 0, + append BOOLEAN NOT NULL DEFAULT FALSE, + last_chunk BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `); + + await client.query(` + CREATE INDEX IF NOT EXISTS "idx_${this.tablePrefix}_artifact_task_id" + ON ${this.artifactTable} (task_id); + `); + + await client.query(` + CREATE INDEX IF NOT EXISTS "idx_${this.tablePrefix}_tasks_status" + ON ${this.taskTable} (status_state); + `); + + await client.query(` + CREATE INDEX IF NOT EXISTS "idx_${this.tablePrefix}_tasks_context" + ON ${this.taskTable} (context_id); + `); + + await client.query(` + CREATE INDEX IF NOT EXISTS "idx_${this.tablePrefix}_tasks_principal" + ON ${this.taskTable} (principal); + `); + } finally { + client.release(); + } + } + + async create(task: Task): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + + const existingCheck = await client.query(`SELECT id FROM ${this.taskTable} WHERE id = $1`, [ + task.id, + ]); + if (existingCheck.rows.length > 0) { + throw new A2AError('TaskAlreadyExistsError', `Task already exists: ${task.id}`); + } + + await client.query( + `INSERT INTO ${this.taskTable} (id, context_id, status_state, status_timestamp, status_message, metadata, principal, tenant_id, history_length) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + task.id, + task.contextId ?? null, + task.status.state, + task.status.timestamp ?? null, + task.status.message ? JSON.stringify(task.status.message) : null, + task.metadata ? JSON.stringify(task.metadata) : null, + task.principal ?? null, + task.tenantId ?? null, + task.historyLength ?? null, + ], + ); + + if (task.history) { + for (const message of task.history) { + await client.query( + `INSERT INTO ${this.historyTable} (task_id, role, parts, message_id, metadata) + VALUES ($1, $2, $3, $4, $5)`, + [ + task.id, + message.role, + JSON.stringify(message.parts), + message.messageId ?? null, + message.metadata ? JSON.stringify(message.metadata) : null, + ], + ); + } + } + + if (task.artifacts) { + for (let i = 0; i < task.artifacts.length; i++) { + const artifact = task.artifacts[i]; + await client.query( + `INSERT INTO ${this.artifactTable} (task_id, name, description, parts, metadata, index, append, last_chunk) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + task.id, + artifact.name ?? null, + artifact.description ?? null, + JSON.stringify(artifact.parts), + artifact.metadata ? JSON.stringify(artifact.metadata) : null, + i, + false, + true, + ], + ); + } + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK').catch(() => {}); + throw err; + } finally { + client.release(); + } + } + + async get(id: string, options?: { historyLength?: number }): Promise { + const client = await this.pool.connect(); + try { + const taskRow = await client.query(`SELECT * FROM ${this.taskTable} WHERE id = $1`, [id]); + + if (taskRow.rows.length === 0) return undefined; + + const task = await this.rowToTask(taskRow.rows[0], client, options?.historyLength); + return task; + } finally { + client.release(); + } + } + + async update( + id: string, + updates: Partial | ((task: Task) => Task), + ): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + + const existingRow = await client.query( + `SELECT * FROM ${this.taskTable} WHERE id = $1 FOR UPDATE`, + [id], + ); + if (existingRow.rows.length === 0) { + await client.query('ROLLBACK'); + return undefined; + } + const existing = await this.rowToTask(existingRow.rows[0], client); + if (!existing) { + await client.query('ROLLBACK'); + return undefined; + } + + const updated = + typeof updates === 'function' ? updates(existing) : { ...existing, ...updates }; + + await client.query( + `UPDATE ${this.taskTable} SET + context_id = $2, + status_state = $3, + status_timestamp = $4, + status_message = $5, + metadata = $6, + principal = $7, + tenant_id = $8, + history_length = $9, + updated_at = NOW() + WHERE id = $1`, + [ + updated.id, + updated.contextId ?? null, + updated.status.state, + updated.status.timestamp ?? null, + updated.status.message ? JSON.stringify(updated.status.message) : null, + updated.metadata ? JSON.stringify(updated.metadata) : null, + updated.principal ?? null, + updated.tenantId ?? null, + updated.historyLength ?? null, + ], + ); + + // Replace history/artifacts so functional updaters that mutate them persist + // (matches the in-memory store's full-object replacement semantics). + if (updated.history !== undefined) { + await client.query(`DELETE FROM ${this.historyTable} WHERE task_id = $1`, [id]); + for (const message of updated.history) { + await client.query( + `INSERT INTO ${this.historyTable} (task_id, role, parts, message_id, metadata) + VALUES ($1, $2, $3, $4, $5)`, + [ + id, + message.role, + JSON.stringify(message.parts), + message.messageId ?? null, + message.metadata ? JSON.stringify(message.metadata) : null, + ], + ); + } + } + + if (updated.artifacts !== undefined) { + await client.query(`DELETE FROM ${this.artifactTable} WHERE task_id = $1`, [id]); + for (let i = 0; i < updated.artifacts.length; i++) { + const artifact = updated.artifacts[i]; + await client.query( + `INSERT INTO ${this.artifactTable} (task_id, name, description, parts, metadata, index, append, last_chunk) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + id, + artifact.name ?? null, + artifact.description ?? null, + JSON.stringify(artifact.parts), + artifact.metadata ? JSON.stringify(artifact.metadata) : null, + i, + false, + true, + ], + ); + } + } + + await client.query('COMMIT'); + return updated; + } catch (err) { + await client.query('ROLLBACK').catch(() => {}); + throw err; + } finally { + client.release(); + } + } + + async list(options?: { + contextId?: string; + status?: string; + principal?: string; + pageSize?: number; + pageToken?: string; + historyLength?: number; + }): Promise<{ tasks: Task[]; nextPageToken: string; totalSize: number }> { + const conditions: string[] = []; + const params: unknown[] = []; + let paramIndex = 1; + + if (options?.contextId) { + conditions.push(`context_id = $${paramIndex++}`); + params.push(options.contextId); + } + if (options?.status) { + conditions.push(`status_state = $${paramIndex++}`); + params.push(options.status); + } + if (options?.principal) { + conditions.push(`principal = $${paramIndex++}`); + params.push(options.principal); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + const countResult = await this.pool.query( + `SELECT COUNT(*) as total FROM ${this.taskTable} ${whereClause}`, + params, + ); + const totalSize = Number.parseInt(countResult.rows[0].total, 10); + + const pageSize = options?.pageSize ?? 50; + const pageOffset = options?.pageToken ? Number.parseInt(options.pageToken, 10) * pageSize : 0; + if (Number.isNaN(pageOffset)) { + throw new A2AError('InvalidPageTokenError', 'invalid pageToken'); + } + + const taskRows = await this.pool.query( + `SELECT * FROM ${this.taskTable} ${whereClause} + ORDER BY created_at DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex++}`, + [...params, pageSize, pageOffset], + ); + + const client = await this.pool.connect(); + let tasks: Task[]; + try { + tasks = []; + for (const row of taskRows.rows) { + const task = await this.rowToTask(row, client, options?.historyLength); + if (task) tasks.push(task); + } + } finally { + client.release(); + } + + const nextPageToken = + pageOffset + pageSize < totalSize ? String(pageOffset / pageSize + 1) : ''; + + return { tasks, nextPageToken, totalSize }; + } + + async cancel(id: string): Promise { + const client = await this.pool.connect(); + try { + const taskRow = await client.query(`SELECT * FROM ${this.taskTable} WHERE id = $1`, [id]); + if (taskRow.rows.length === 0) return undefined; + + const terminalStates = ['completed', 'failed', 'canceled', 'rejected']; + if (terminalStates.includes(taskRow.rows[0].status_state)) { + return undefined; + } + + const now = new Date().toISOString(); + await client.query( + `UPDATE ${this.taskTable} SET status_state = 'canceled', status_timestamp = $2, updated_at = NOW() WHERE id = $1`, + [id, now], + ); + + const task = await this.get(id); + return task; + } finally { + client.release(); + } + } + + async addHistory(id: string, message: Message): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + + const taskRow = await client.query( + `SELECT history_length FROM ${this.taskTable} WHERE id = $1 FOR UPDATE`, + [id], + ); + if (taskRow.rows.length === 0) { + await client.query('ROLLBACK'); + throw new TaskNotFoundError(id); + } + + await client.query( + `INSERT INTO ${this.historyTable} (task_id, role, parts, message_id, metadata) + VALUES ($1, $2, $3, $4, $5)`, + [ + id, + message.role, + JSON.stringify(message.parts), + message.messageId ?? null, + message.metadata ? JSON.stringify(message.metadata) : null, + ], + ); + + const rawLength = taskRow.rows[0].history_length; + if (rawLength !== null && typeof rawLength === 'number' && rawLength >= 0) { + await client.query( + `DELETE FROM ${this.historyTable} + WHERE task_id = $1 + AND id NOT IN ( + SELECT id FROM ${this.historyTable} + WHERE task_id = $1 + ORDER BY id DESC + LIMIT $2 + )`, + [id, rawLength], + ); + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK').catch(() => {}); + throw err; + } finally { + client.release(); + } + } + + async addArtifact(id: string, artifact: Artifact): Promise { + const client = await this.pool.connect(); + try { + await client.query('BEGIN'); + + const maxIndex = await client.query( + `SELECT COALESCE(MAX(index), -1) + 1 as next_index FROM ${this.artifactTable} WHERE task_id = $1`, + [id], + ); + + await client.query( + `INSERT INTO ${this.artifactTable} (task_id, name, description, parts, metadata, index, append, last_chunk) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + [ + id, + artifact.name ?? null, + artifact.description ?? null, + JSON.stringify(artifact.parts), + artifact.metadata ? JSON.stringify(artifact.metadata) : null, + maxIndex.rows[0].next_index, + false, + true, + ], + ); + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK').catch(() => {}); + throw err; + } finally { + client.release(); + } + } + + async updateStatus(id: string, status: TaskStatus): Promise { + const result = await this.pool.query( + `UPDATE ${this.taskTable} SET status_state = $2, status_timestamp = $3, status_message = $4, updated_at = NOW() + WHERE id = $1`, + [ + id, + status.state, + status.timestamp ?? null, + status.message ? JSON.stringify(status.message) : null, + ], + ); + if (result.rowCount === 0) { + throw new TaskNotFoundError(id); + } + } + + private async rowToTask( + row: Record, + maybeClient: PoolClient | undefined, + historyLength?: number, + ): Promise { + if (typeof row.id !== 'string' || !row.id) return undefined; + + const client = maybeClient ?? (await this.pool.connect()); + const shouldRelease = !maybeClient; + + try { + const historyRows = await client.query( + `SELECT * FROM ${this.historyTable} WHERE task_id = $1 ORDER BY id ASC`, + [row.id], + ); + const artifactRows = await client.query( + `SELECT * FROM ${this.artifactTable} WHERE task_id = $1 ORDER BY index ASC`, + [row.id], + ); + + function parseJsonField(val: unknown): unknown { + if (typeof val === 'string') { + try { + return JSON.parse(val); + } catch { + return val; + } + } + return val; + } + + const history = historyRows.rows + .filter((h: Record) => { + if (typeof h.role === 'string') return true; + defaultLogger.warn( + { taskId: row.id, historyEntry: h }, + 'history entry dropped: missing or invalid role', + ); + return false; + }) + .map((h: Record) => { + const parsedParts = parseJsonField(h.parts); + return { + messageId: + typeof h.message_id === 'string' ? h.message_id : `msg-${h.id ?? Date.now()}`, + role: h.role as 'user' | 'agent', + parts: (Array.isArray(parsedParts) ? parsedParts : []) as Message['parts'], + ...(h.metadata + ? { metadata: parseJsonField(h.metadata) as Record } + : {}), + }; + }); + + const artifacts = artifactRows.rows + .filter((a: Record) => Array.isArray(parseJsonField(a.parts))) + .map((a: Record) => ({ + ...(typeof a.name === 'string' ? { name: a.name } : {}), + ...(typeof a.description === 'string' ? { description: a.description } : {}), + parts: parseJsonField(a.parts) as Artifact['parts'], + ...(a.metadata + ? { metadata: parseJsonField(a.metadata) as Record } + : {}), + })); + + const statusState = row.status_state; + const validStates = [ + 'submitted', + 'working', + 'input-required', + 'completed', + 'failed', + 'canceled', + 'rejected', + 'auth-required', + ]; + const state = + typeof statusState === 'string' && validStates.includes(statusState) + ? (statusState as Task['status']['state']) + : 'failed'; + + const task: Task = { + id: row.id as string, + ...(typeof row.context_id === 'string' ? { contextId: row.context_id } : {}), + status: { + state: state as Task['status']['state'], + ...(typeof row.status_timestamp === 'string' ? { timestamp: row.status_timestamp } : {}), + ...(row.status_message ? { message: parseJsonField(row.status_message) as Message } : {}), + }, + ...(history.length > 0 ? { history } : {}), + ...(artifacts.length > 0 ? { artifacts } : {}), + ...(row.metadata + ? { metadata: parseJsonField(row.metadata) as Record } + : {}), + ...(typeof row.principal === 'string' ? { principal: row.principal } : {}), + ...(typeof row.tenant_id === 'string' ? { tenantId: row.tenant_id } : {}), + ...(typeof row.history_length === 'number' ? { historyLength: row.history_length } : {}), + }; + + return applyHistoryLength(task, historyLength); + } finally { + if (shouldRelease) client.release(); + } + } + + async close(): Promise { + await this.pool.end(); + } +} diff --git a/packages/persistence/src/redis.ts b/packages/persistence/src/redis.ts index fd9fdea..c50f94b 100644 --- a/packages/persistence/src/redis.ts +++ b/packages/persistence/src/redis.ts @@ -52,6 +52,7 @@ export class RedisTaskStore implements TaskStore { async list(options?: { contextId?: string; status?: string; + principal?: string; pageSize?: number; pageToken?: string; historyLength?: number; @@ -81,6 +82,9 @@ export class RedisTaskStore implements TaskStore { if (options?.status) { tasks = tasks.filter((t) => t.status.state === options.status); } + if (options?.principal) { + tasks = tasks.filter((t) => t.principal === options.principal); + } tasks.sort((a, b) => { const aTime = a.status.timestamp ?? ''; diff --git a/packages/persistence/src/store.ts b/packages/persistence/src/store.ts index 6c19979..c0384d6 100644 --- a/packages/persistence/src/store.ts +++ b/packages/persistence/src/store.ts @@ -7,6 +7,7 @@ export interface TaskStore { list(options?: { contextId?: string; status?: string; + principal?: string; pageSize?: number; pageToken?: string; historyLength?: number; diff --git a/packages/server/README.md b/packages/server/README.md index be82364..73c6a56 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -1,282 +1,120 @@ # @reaatech/a2a-reference-server -[![npm version](https://img.shields.io/npm/v/@reaatech/a2a-reference-server.svg)](https://www.npmjs.com/package/@reaatech/a2a-reference-server) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/reaatech/a2a-reference-ts/blob/main/LICENSE) -[![CI](https://img.shields.io/github/actions/workflow/status/reaatech/a2a-reference-ts/ci.yml?branch=main&label=CI)](https://github.com/reaatech/a2a-reference-ts/actions/workflows/ci.yml) - -> **Status:** Pre-1.0 — APIs may change in minor versions. Pin to a specific version in production. - -A2A server framework for building interoperable AI agents. Provides Express and Hono adapters with JSON-RPC 2.0 routing, Server-Sent Events (SSE) streaming, task lifecycle management, and pluggable authentication. - -## Installation - -```bash -npm install @reaatech/a2a-reference-server -# or -pnpm add @reaatech/a2a-reference-server -``` - -## Feature Overview - -- **Express 5 and Hono 4 adapters** — choose the framework that fits your stack -- **JSON-RPC 2.0 routing** — standards-compliant method dispatch with schema validation -- **SSE streaming** — real-time task status and artifact updates to connected clients -- **Task lifecycle** — built-in state machine with validated transitions -- **Pluggable persistence** — swap in-memory, file-system, or Redis task stores -- **Pluggable authentication** — integrate API key, JWT, or custom auth strategies -- **Graceful shutdown** — drains in-flight tasks and closes SSE connections cleanly +A2A server framework with Express 5 and Hono adapters. ## Quick Start -Create an A2A agent with a single skill in under 30 lines: - -```typescript -import { createA2AExpressApp } from "@reaatech/a2a-reference-server"; -import type { AgentExecutor, ExecutionContext, ExecutionEventBus } from "@reaatech/a2a-reference-server"; - -const agentCard = { - name: "Greeter", - description: "A friendly agent that greets users", - url: "http://localhost:3000", - version: "1.0.0", - protocolVersion: "0.3.0", - capabilities: { streaming: false }, - defaultInputModes: ["text/plain"], - defaultOutputModes: ["text/plain"], - skills: [ - { - id: "greet", - name: "Greet User", - description: "Returns a personalized greeting", - tags: ["greeting"], - examples: ["Hello!", "Say hi"], - }, - ], -}; +```ts +import { createA2AExpressApp } from '@reaatech/a2a-reference-server'; -const executor: AgentExecutor = { - async execute(context: ExecutionContext, eventBus: ExecutionEventBus) { - const text = context.message.parts - .filter((p) => p.kind === "text") - .map((p) => p.text) - .join(" "); - - eventBus.emitStatusUpdate({ kind: "status", status: { state: "working" } }); - eventBus.emitArtifactUpdate({ - kind: "artifact", - artifact: { - name: "response", - parts: [{ kind: "text", text: `Hello! You said: "${text}"` }], - }, - }); - eventBus.emitStatusUpdate({ kind: "status", status: { state: "completed" } }); +const app = createA2AExpressApp({ + agentCard: { /* ... */ }, + executor: { + async execute(context, eventBus) { /* your logic */ }, }, -}; +}); -const app = createA2AExpressApp({ agentCard, executor }); -app.listen(3000, () => console.log("A2A agent listening on :3000")); +app.listen(3000); ``` -## Using Hono +## Features -```typescript -import { createA2AHonoApp } from "@reaatech/a2a-reference-server"; +### Dual Adapters +```ts +// Express +const app = createA2AExpressApp({ agentCard, executor }); +// Hono (edge-compatible) const app = createA2AHonoApp({ agentCard, executor }); -export default app; -``` - -## API Reference - -### Express Adapter - -#### `createA2AExpressApp(options: A2AServerOptions): Express & { shutdown }` - -Creates a fully configured Express 5 application with all A2A routes, JSON body parsing, and graceful shutdown. - -#### `createA2ARouter(options: A2AServerOptions): Router & { shutdownSse }` - -Creates an Express Router with A2A endpoints. Mount it on an existing Express app under a path prefix. - -#### `A2AServerOptions` - -| Property | Type | Required | Description | -|----------|------|----------|-------------| -| `agentCard` | `AgentCard` | Yes | The agent's metadata card describing capabilities, skills, and interfaces | -| `executor` | `AgentExecutor` | Yes | Your task execution logic | -| `taskStore` | `TaskStore` | No | Persistence layer; defaults to `InMemoryTaskStore` | -| `authStrategy` | `AuthStrategy` | No | Authentication strategy from `@reaatech/a2a-reference-auth` | - -#### `A2AServerShutdownOptions` - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `timeoutMs` | `number` | `10000` | Max ms to wait for in-flight tasks during shutdown | - -### Hono Adapter - -#### `createA2AHonoApp(options: A2AHonoOptions): Hono & { shutdown }` - -Creates a fully configured Hono application with A2A routes and SSE streaming via `ReadableStream`. - -#### `A2AHonoOptions` - -Same shape as `A2AServerOptions`. See above. - -#### `A2AHonoShutdownOptions` - -Same as `A2AServerShutdownOptions`. See above. - -### Execution Model - -#### `AgentExecutor` - -Your implementation contract: - -```typescript -interface AgentExecutor { - execute(context: ExecutionContext, eventBus: ExecutionEventBus): Promise; - cancelTask?(taskId: string, eventBus: ExecutionEventBus): Promise; -} ``` -#### `ExecutionContext` - -| Property | Type | Description | -|----------|------|-------------| -| `task` | `Task` | The task object at creation time | -| `message` | `Message` | The triggering user message with its `parts` array | - -#### `ExecutionEventBus` - -The channel for reporting progress back to the server: - -| Method | Description | -|--------|-------------| -| `emitStatusUpdate(event: TaskStatusUpdateEvent)` | Update task status, validate transition, broadcast to SSE subscribers | -| `emitArtifactUpdate(event: TaskArtifactUpdateEvent)` | Persist and broadcast an artifact to SSE subscribers | - -### JSON-RPC Router - -The `JsonRpcRouter` class provides generic JSON-RPC 2.0 dispatch with context support: - -```typescript -import { JsonRpcRouter } from "@reaatech/a2a-reference-server"; - -const router = new JsonRpcRouter(); - -router.register("myMethod", async (params, context) => { - // context === "some-context" - return { success: true }; +### Health Checks +```ts +const app = createA2AExpressApp({ + agentCard, + executor, + healthChecks: [{ name: 'db', check: async () => ({ status: 'ok' }) }], }); - -const response = await router.handle( - { jsonrpc: "2.0", method: "myMethod", params: { key: "value" }, id: 1 }, - "some-context" -); +// GET /healthz, GET /readyz ``` -| Method | Description | -|--------|-------------| -| `register(method, handler)` | Register a named JSON-RPC 2.0 method handler | -| `handle(request, context?)` | Parse, validate, dispatch, and return a JSON-RPC 2.0 response | - -Error codes: `-32700` (parse), `-32601` (method not found), `-32602` (invalid params), `-32603` (internal). - -### Server Endpoints +### Rate Limiting +```ts +import { RateLimiter } from '@reaatech/a2a-reference-server'; -| Method | Path | Description | -|--------|------|-------------| -| `GET` | `/.well-known/agent.json` | Returns the `AgentCard` as JSON | -| `GET` | `/.well-known/agent-card` | Alternative discovery endpoint | -| `POST` | `/` | JSON-RPC 2.0 endpoint (dispatches `tasks/send`, `tasks/get`, `tasks/list`, `tasks/cancel`) | -| `POST` | `/tasks/sendSubscribe` | Creates a task and returns an SSE event stream | -| `GET` | `/tasks/:taskId/subscribe` | Subscribes to SSE updates for an existing task | - -### Task State Machine - -Valid state transitions are enforced automatically: - -``` -submitted → working, input-required, completed, failed, canceled, rejected -working → input-required, completed, failed, canceled -input-required → working, completed, failed, canceled -completed, failed, canceled, rejected → (terminal) +const app = createA2AExpressApp({ + agentCard, + executor, + rateLimiter: new RateLimiter({ windowMs: 60_000, maxRequests: 100 }), +}); ``` -### Graceful Shutdown - -```typescript -// Gracefully stop the server -await app.shutdown({ timeoutMs: 5000 }); +### Push Notifications +```ts +import { PushNotificationManager } from '@reaatech/a2a-reference-server'; -// Or just close SSE connections without waiting for tasks -await app.shutdownSse(); +const app = createA2AExpressApp({ + agentCard: { ...agentCard, capabilities: { pushNotifications: true } }, + executor, + pushNotificationManager: new PushNotificationManager({ maxRetries: 3 }), +}); ``` -## Advanced: Streaming Tasks - -```typescript -const executor: AgentExecutor = { - async execute(context: ExecutionContext, eventBus: ExecutionEventBus) { - eventBus.emitStatusUpdate({ kind: "status", status: { state: "working" } }); - - for (let i = 1; i <= 5; i++) { - await new Promise((r) => setTimeout(r, 500)); - eventBus.emitArtifactUpdate({ - kind: "artifact", - artifact: { - name: "progress", - parts: [{ kind: "text", text: `Step ${i} of 5 complete` }], - }, - }); - } - - eventBus.emitArtifactUpdate({ - kind: "artifact", - artifact: { - name: "result", - parts: [{ kind: "text", text: "All steps finished!" }], - }, - }); - eventBus.emitStatusUpdate({ kind: "status", status: { state: "completed" } }); - }, -}; +### Extended Agent Card +```ts +const app = createA2AExpressApp({ + agentCard, + executor, + extendedAgentCard: { customData: '...' }, +}); +// GET /.well-known/agent-card/extended +// RPC: tasks/extendedAgentCard ``` -## Advanced: Authentication - -```typescript -import { ApiKeyStrategy } from "@reaatech/a2a-reference-auth"; +### SSE via Redis Pub/Sub +```ts +import { RedisSseCoordinator } from '@reaatech/a2a-reference-server'; +import { Redis } from 'ioredis'; -const authStrategy = new ApiKeyStrategy({ - keys: new Set(["my-secret-key"]), +const coordinator = new RedisSseCoordinator({ + redis: new Redis(), + connectOnInit: true, // auto-connects }); - -const app = createA2AExpressApp({ agentCard, executor, authStrategy }); ``` -## Advanced: Persistent Storage - -```typescript -import { FileSystemTaskStore } from "@reaatech/a2a-reference-persistence"; - -const taskStore = new FileSystemTaskStore({ path: "./tasks.json" }); -await taskStore.load(); - -const app = createA2AExpressApp({ agentCard, executor, taskStore }); - -// On shutdown -await taskStore.close(); +### Auth Integration +```ts +const app = createA2AExpressApp({ + agentCard, + executor, + authStrategy: new JwtStrategy({ jwksUri: 'https://auth.example.com/jwks' }), +}); ``` -## Related Packages +## Server Options -- [`@reaatech/a2a-reference-core`](https://www.npmjs.com/package/@reaatech/a2a-reference-core) — Protocol types and Zod schemas -- [`@reaatech/a2a-reference-client`](https://www.npmjs.com/package/@reaatech/a2a-reference-client) — Client SDK -- [`@reaatech/a2a-reference-auth`](https://www.npmjs.com/package/@reaatech/a2a-reference-auth) — Authentication strategies -- [`@reaatech/a2a-reference-persistence`](https://www.npmjs.com/package/@reaatech/a2a-reference-persistence) — Task store implementations +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `agentCard` | `AgentCard` | required | Agent discovery metadata | +| `executor` | `AgentExecutor` | required | Your agent logic | +| `taskStore` | `TaskStore` | `InMemoryTaskStore` | Task persistence | +| `authStrategy` | `AuthStrategy` | none | Authentication | +| `rateLimiter` | `RateLimiter` | none | Request rate limiting | +| `extendedAgentCard` | `Record` | none | Extended metadata | +| `pushNotificationManager` | `PushNotificationManager` | auto | Webhook delivery | +| `healthChecks` | `HealthCheck[]` | none | Custom health probes | +| `version` | `string` | agentCard.version | Version for health endpoint | +| `trustProxyHeaders` | `boolean` | `false` | Derive the rate-limit client IP from `X-Forwarded-For` (only enable behind a trusted proxy) | -## License +## Endpoints -[MIT](https://github.com/reaatech/a2a-reference-ts/blob/main/LICENSE) +| Method | Path | Description | +|--------|------|-------------| +| GET | `/.well-known/agent.json` | Agent Card | +| GET | `/.well-known/agent-card` | Agent Card | +| GET | `/.well-known/agent-card/extended` | Extended Agent Card | +| GET | `/healthz` | Health check | +| GET | `/readyz` | Readiness check | +| POST | `/` | JSON-RPC 2.0 endpoint | +| POST | `/tasks/sendSubscribe` | SSE streaming | +| GET | `/tasks/:taskId/subscribe` | SSE subscription | diff --git a/packages/server/package.json b/packages/server/package.json index a6df812..add7d8c 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -41,6 +41,7 @@ "@reaatech/a2a-reference-persistence": "workspace:*", "express": "^5.1.0", "hono": "^4.12.18", + "ioredis": "^5.10.1", "zod": "^3.24.2" }, "devDependencies": { diff --git a/packages/server/src/executor.test.ts b/packages/server/src/executor.test.ts new file mode 100644 index 0000000..b70ea48 --- /dev/null +++ b/packages/server/src/executor.test.ts @@ -0,0 +1,175 @@ +import type { Message, Task } from '@reaatech/a2a-reference-core'; +import { describe, expect, it, vi } from 'vitest'; +import type { AgentExecutor, ExecutionContext, ExecutionEventBus } from './executor.js'; + +describe('AgentExecutor interface', () => { + it('supports a mock executor that implements the contract', async () => { + const emitStatusUpdate = vi.fn().mockResolvedValue(undefined); + const emitArtifactUpdate = vi.fn().mockResolvedValue(undefined); + + const eventBus: ExecutionEventBus = { + emitStatusUpdate, + emitArtifactUpdate, + }; + + const task: Task = { + id: 'task-1', + status: { state: 'submitted', timestamp: new Date().toISOString() }, + }; + + const message: Message = { + messageId: 'msg-1', + role: 'user', + parts: [{ kind: 'text', text: 'Hello' }], + }; + + const context: ExecutionContext = { task, message }; + + const executor: AgentExecutor = { + async execute(_ctx, bus) { + await bus.emitStatusUpdate({ + kind: 'status', + status: { state: 'working' }, + }); + await bus.emitArtifactUpdate({ + kind: 'artifact', + artifact: { parts: [{ kind: 'text', text: 'result' }] }, + }); + await bus.emitStatusUpdate({ + kind: 'status', + status: { state: 'completed' }, + final: true, + }); + }, + }; + + await executor.execute(context, eventBus); + + expect(emitStatusUpdate).toHaveBeenCalledTimes(2); + expect(emitStatusUpdate).toHaveBeenNthCalledWith(1, { + kind: 'status', + status: { state: 'working' }, + }); + expect(emitStatusUpdate).toHaveBeenNthCalledWith(2, { + kind: 'status', + status: { state: 'completed' }, + final: true, + }); + expect(emitArtifactUpdate).toHaveBeenCalledTimes(1); + expect(emitArtifactUpdate).toHaveBeenCalledWith({ + kind: 'artifact', + artifact: { parts: [{ kind: 'text', text: 'result' }] }, + }); + }); + + it('supports cancelTask as an optional method', async () => { + const emitStatusUpdate = vi.fn().mockResolvedValue(undefined); + const emitArtifactUpdate = vi.fn().mockResolvedValue(undefined); + const eventBus: ExecutionEventBus = { emitStatusUpdate, emitArtifactUpdate }; + + const cancelTask = vi.fn().mockResolvedValue(undefined); + + const executor: AgentExecutor = { + async execute(_ctx, _bus) {}, + cancelTask, + }; + + const task: Task = { + id: 'task-1', + status: { state: 'working', timestamp: new Date().toISOString() }, + }; + const message: Message = { + messageId: 'msg-1', + role: 'user', + parts: [{ kind: 'text', text: 'cancel' }], + }; + + await executor.execute({ task, message }, eventBus); + await executor.cancelTask?.('task-1', eventBus); + + expect(cancelTask).toHaveBeenCalledWith('task-1', eventBus); + }); + + it('executes without cancelTask when not provided', () => { + const executor: AgentExecutor = { + async execute() {}, + }; + expect(executor.cancelTask).toBeUndefined(); + }); + + it('provides full ExecutionContext shape', () => { + const task: Task = { + id: 'task-1', + status: { state: 'submitted', timestamp: new Date().toISOString() }, + artifacts: [{ parts: [{ kind: 'text', text: 'intermediate' }] }], + history: [ + { + messageId: 'msg-0', + role: 'user', + parts: [{ kind: 'text', text: 'prior' }], + }, + ], + metadata: { source: 'test' }, + principal: 'alice', + tenantId: 'tenant-1', + }; + + const message: Message = { + messageId: 'msg-1', + role: 'user', + parts: [{ kind: 'text', text: 'Hello' }], + }; + + const context: ExecutionContext = { task, message }; + + expect(context.task).toBe(task); + expect(context.message).toBe(message); + expect(context.task.artifacts).toHaveLength(1); + expect(context.task.history).toHaveLength(1); + expect(context.task.principal).toBe('alice'); + }); + + it('emits events through ExecutionEventBus in expected order', async () => { + const events: string[] = []; + const eventBus: ExecutionEventBus = { + async emitStatusUpdate(event) { + events.push(`status:${event.status.state}`); + }, + async emitArtifactUpdate(event) { + events.push(`artifact:${event.artifact.parts[0].kind}`); + }, + }; + + const executor: AgentExecutor = { + async execute(_ctx, bus) { + await bus.emitStatusUpdate({ + kind: 'status', + status: { state: 'working' }, + }); + await bus.emitArtifactUpdate({ + kind: 'artifact', + artifact: { parts: [{ kind: 'text', text: 'progress' }] }, + }); + await bus.emitStatusUpdate({ + kind: 'status', + status: { state: 'completed' }, + final: true, + }); + }, + }; + + const task: Task = { + id: 'task-1', + status: { state: 'submitted', timestamp: new Date().toISOString() }, + }; + const message: Message = { + messageId: 'msg-1', + role: 'user', + parts: [{ kind: 'text', text: 'go' }], + }; + + await executor.execute({ task, message }, eventBus); + + expect(events).toEqual(['status:working', 'artifact:text', 'status:completed']); + }); +}); diff --git a/packages/server/src/executor.ts b/packages/server/src/executor.ts index 7562b38..0f6a4b7 100644 --- a/packages/server/src/executor.ts +++ b/packages/server/src/executor.ts @@ -11,8 +11,8 @@ export interface ExecutionContext { } export interface ExecutionEventBus { - emitStatusUpdate(event: TaskStatusUpdateEvent): void; - emitArtifactUpdate(event: TaskArtifactUpdateEvent): void; + emitStatusUpdate(event: TaskStatusUpdateEvent): Promise; + emitArtifactUpdate(event: TaskArtifactUpdateEvent): Promise; } export interface AgentExecutor { diff --git a/packages/server/src/express.test.ts b/packages/server/src/express.test.ts index 153a971..8228fa5 100644 --- a/packages/server/src/express.test.ts +++ b/packages/server/src/express.test.ts @@ -4,6 +4,7 @@ import request from 'supertest'; import { describe, expect, it } from 'vitest'; import type { AgentExecutor, ExecutionContext, ExecutionEventBus } from './executor.js'; import { createA2AExpressApp } from './express.js'; +import { RateLimiter } from './rate-limiter.js'; const testAgentCard = { name: 'Echo Agent', @@ -420,4 +421,507 @@ describe('createA2AExpressApp', () => { const res = await sseReq; expect(res.status).toBe(200); }); + + describe('health check endpoints', () => { + it('GET /healthz returns 200 with ok status', async () => { + const app = createA2AExpressApp({ agentCard: testAgentCard, executor: echoExecutor }); + const res = await request(app).get('/healthz'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); + }); + + it('GET /readyz returns 200 when healthy', async () => { + const app = createA2AExpressApp({ agentCard: testAgentCard, executor: echoExecutor }); + const res = await request(app).get('/readyz'); + expect(res.status).toBe(200); + expect(res.body.status).toBe('ok'); + }); + + it('health endpoint includes version and uptime', async () => { + const app = createA2AExpressApp({ agentCard: testAgentCard, executor: echoExecutor }); + const res = await request(app).get('/healthz'); + expect(res.body.version).toBe('1.0.0'); + expect(typeof res.body.uptime).toBe('number'); + expect(res.body.timestamp).toBeDefined(); + }); + }); + + describe('rate limiting', () => { + it('requests under limit succeed', async () => { + const limiter = new RateLimiter({ windowMs: 60000, maxRequests: 10 }); + const app = createA2AExpressApp({ + agentCard: testAgentCard, + executor: echoExecutor, + rateLimiter: limiter, + }); + const res = await request(app).get('/.well-known/agent.json'); + expect(res.status).toBe(200); + }); + + it('requests over limit get 429', async () => { + const limiter = new RateLimiter({ windowMs: 60000, maxRequests: 1 }); + const app = createA2AExpressApp({ + agentCard: testAgentCard, + executor: echoExecutor, + rateLimiter: limiter, + }); + await request(app).get('/.well-known/agent.json'); + const res = await request(app).get('/.well-known/agent.json'); + expect(res.status).toBe(429); + expect(res.body.error).toBe('Too Many Requests'); + }); + + it('X-RateLimit-Remaining header is present', async () => { + const limiter = new RateLimiter({ windowMs: 60000, maxRequests: 10 }); + const app = createA2AExpressApp({ + agentCard: testAgentCard, + executor: echoExecutor, + rateLimiter: limiter, + }); + const res = await request(app).get('/.well-known/agent.json'); + expect(res.headers['x-ratelimit-remaining']).toBeDefined(); + expect(Number(res.headers['x-ratelimit-remaining'])).toBeGreaterThanOrEqual(0); + }); + }); + + describe('extended agent card', () => { + it('GET /.well-known/agent-card/extended returns extended card when configured', async () => { + const app = createA2AExpressApp({ + agentCard: testAgentCard, + executor: echoExecutor, + extendedAgentCard: { description: 'extended info', customField: 'value' }, + }); + const res = await request(app).get('/.well-known/agent-card/extended'); + expect(res.status).toBe(200); + expect(res.body.description).toBe('extended info'); + expect(res.body.customField).toBe('value'); + }); + + it('GET /.well-known/agent-card/extended returns 404 when not configured', async () => { + const app = createA2AExpressApp({ agentCard: testAgentCard, executor: echoExecutor }); + const res = await request(app).get('/.well-known/agent-card/extended'); + expect(res.status).toBe(404); + expect(res.body.error).toBe('Extended agent card not configured'); + }); + }); + + describe('push notification RPCs', () => { + const pushAgentCard = { + ...testAgentCard, + name: 'Push Agent', + capabilities: { ...testAgentCard.capabilities, pushNotifications: true }, + }; + + it('tasks/pushNotification/set registers config', async () => { + const app = createA2AExpressApp({ agentCard: pushAgentCard, executor: echoExecutor }); + const res = await request(app) + .post('/') + .send({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/pushNotification/set', + params: { id: 'cfg-1', taskId: 'task-1', url: 'https://example.com/callback' }, + }); + expect(res.status).toBe(200); + expect(res.body.result.ok).toBe(true); + expect(res.body.result.id).toBe('cfg-1'); + }); + + it('tasks/pushNotification/get returns config', async () => { + const app = createA2AExpressApp({ agentCard: pushAgentCard, executor: echoExecutor }); + await request(app) + .post('/') + .send({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/pushNotification/set', + params: { id: 'cfg-2', taskId: 'task-2', url: 'https://example.com/callback' }, + }); + const res = await request(app) + .post('/') + .send({ + jsonrpc: '2.0', + id: 2, + method: 'tasks/pushNotification/get', + params: { taskId: 'task-2' }, + }); + expect(res.status).toBe(200); + expect(res.body.result.taskId).toBe('task-2'); + expect(res.body.result.url).toBe('https://example.com/callback'); + }); + + it('tasks/pushNotification/list returns configs', async () => { + const app = createA2AExpressApp({ agentCard: pushAgentCard, executor: echoExecutor }); + await request(app) + .post('/') + .send({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/pushNotification/set', + params: { id: 'cfg-3', taskId: 'task-3', url: 'https://example.com/callback' }, + }); + const res = await request(app).post('/').send({ + jsonrpc: '2.0', + id: 2, + method: 'tasks/pushNotification/list', + params: {}, + }); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.result.configs)).toBe(true); + expect(res.body.result.configs).toHaveLength(1); + }); + + it('tasks/pushNotification/delete removes config', async () => { + const app = createA2AExpressApp({ agentCard: pushAgentCard, executor: echoExecutor }); + await request(app) + .post('/') + .send({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/pushNotification/set', + params: { id: 'cfg-4', taskId: 'task-4', url: 'https://example.com/callback' }, + }); + await request(app) + .post('/') + .send({ + jsonrpc: '2.0', + id: 2, + method: 'tasks/pushNotification/delete', + params: { taskId: 'task-4' }, + }); + const getRes = await request(app) + .post('/') + .send({ + jsonrpc: '2.0', + id: 3, + method: 'tasks/pushNotification/get', + params: { taskId: 'task-4' }, + }); + expect(getRes.status).toBe(200); + expect(getRes.body.error).toBeDefined(); + expect(getRes.body.error.message).toContain('not found'); + }); + }); + + describe('SSE endpoint executor behavior', () => { + it('handles executor failure in SSE endpoint', async () => { + const throwingExecutor: AgentExecutor = { + async execute() { + throw new Error('SSE failed'); + }, + }; + const app = createA2AExpressApp({ agentCard: testAgentCard, executor: throwingExecutor }); + const res = await request(app) + .post('/tasks/sendSubscribe') + .send({ + message: { + messageId: 'msg-1', + role: 'user', + parts: [{ kind: 'text', text: 'Hello' }], + }, + }) + .buffer(true) + .parse((res, callback) => { + res.setEncoding('utf8'); + let data = ''; + res.on('data', (chunk: string) => { + data += chunk; + }); + res.on('end', () => { + callback(null, data); + }); + }); + + expect(res.status).toBe(200); + const body = res.body as string; + expect(body).toContain('"kind":"task"'); + expect(body).toContain('"state":"failed"'); + expect(body).toContain('"final":true'); + }); + + it('auto-completes task when executor does not set final state in SSE', async () => { + const noFinalStateExecutor: AgentExecutor = { + async execute(_ctx: ExecutionContext, eventBus: ExecutionEventBus) { + await eventBus.emitStatusUpdate({ kind: 'status', status: { state: 'working' } }); + }, + }; + const app = createA2AExpressApp({ agentCard: testAgentCard, executor: noFinalStateExecutor }); + const res = await request(app) + .post('/tasks/sendSubscribe') + .send({ + message: { + messageId: 'msg-1', + role: 'user', + parts: [{ kind: 'text', text: 'Hello' }], + }, + }) + .buffer(true) + .parse((res, callback) => { + res.setEncoding('utf8'); + let data = ''; + res.on('data', (chunk: string) => { + data += chunk; + }); + res.on('end', () => { + callback(null, data); + }); + }); + + expect(res.status).toBe(200); + const body = res.body as string; + expect(body).toContain('"state":"completed"'); + expect(body).toContain('"final":true'); + }); + }); + + describe('tasks/send auto-complete transition', () => { + it('transitions to completed when executor does not set final state', async () => { + const noFinalStateExecutor: AgentExecutor = { + async execute(_ctx: ExecutionContext, eventBus: ExecutionEventBus) { + await eventBus.emitStatusUpdate({ kind: 'status', status: { state: 'working' } }); + }, + }; + const app = createA2AExpressApp({ agentCard: testAgentCard, executor: noFinalStateExecutor }); + + const sendRes = await request(app) + .post('/') + .send({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/send', + params: { + message: { + messageId: 'msg-1', + role: 'user', + parts: [{ kind: 'text', text: 'Hello' }], + }, + }, + }); + expect(sendRes.body.result.status.state).toBe('submitted'); + + await new Promise((r) => setTimeout(r, 100)); + + const getRes = await request(app) + .post('/') + .send({ + jsonrpc: '2.0', + id: 2, + method: 'tasks/get', + params: { id: sendRes.body.result.id }, + }); + expect(getRes.body.result.status.state).toBe('completed'); + }); + }); + + describe('executor.cancelTask', () => { + it('calls executor.cancelTask when available in tasks/cancel', async () => { + let cancelCalled = false; + const cancelableExecutor: AgentExecutor = { + async execute(_ctx: ExecutionContext, _bus: ExecutionEventBus) { + await new Promise((resolve) => setTimeout(resolve, 100)); + }, + async cancelTask(_taskId: string, _bus: ExecutionEventBus) { + cancelCalled = true; + }, + }; + const app = createA2AExpressApp({ agentCard: testAgentCard, executor: cancelableExecutor }); + + const sendRes = await request(app) + .post('/') + .send({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/send', + params: { + message: { + messageId: 'msg-1', + role: 'user', + parts: [{ kind: 'text', text: 'Hello' }], + }, + }, + }); + const taskId = sendRes.body.result.id; + + await new Promise((r) => setTimeout(r, 10)); + + await request(app) + .post('/') + .send({ + jsonrpc: '2.0', + id: 2, + method: 'tasks/cancel', + params: { id: taskId }, + }); + expect(cancelCalled).toBe(true); + }); + }); + + describe('JSON-RPC tasks/sendSubscribe', () => { + it('handles tasks/sendSubscribe via JSON-RPC', async () => { + const app = createA2AExpressApp({ agentCard: testAgentCard, executor: echoExecutor }); + const res = await request(app) + .post('/') + .send({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/sendSubscribe', + params: { + message: { + messageId: 'msg-1', + role: 'user', + parts: [{ kind: 'text', text: 'Hello' }], + }, + }, + }); + expect(res.status).toBe(200); + expect(res.body.result.id).toBeDefined(); + expect(res.body.result.status.state).toBe('submitted'); + }); + + it('handles executor failure in tasks/sendSubscribe RPC', async () => { + const throwingExecutor: AgentExecutor = { + async execute() { + throw new Error('RPC sendSubscribe failed'); + }, + }; + const app = createA2AExpressApp({ agentCard: testAgentCard, executor: throwingExecutor }); + const res = await request(app) + .post('/') + .send({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/sendSubscribe', + params: { + message: { + messageId: 'msg-1', + role: 'user', + parts: [{ kind: 'text', text: 'Hello' }], + }, + }, + }); + expect(res.status).toBe(200); + expect(res.body.result.id).toBeDefined(); + expect(res.body.result.status.state).toBe('submitted'); + }); + }); + + describe('push notification not-supported', () => { + it('tasks/pushNotification/set throws when not supported', async () => { + const app = createA2AExpressApp({ agentCard: testAgentCard, executor: echoExecutor }); + const res = await request(app) + .post('/') + .send({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/pushNotification/set', + params: { id: 'cfg-1', taskId: 'task-1', url: 'https://example.com/callback' }, + }); + expect(res.status).toBe(200); + expect(res.body.error).toBeDefined(); + expect(res.body.error.message).toContain('not supported'); + }); + + it('tasks/pushNotification/get throws when not supported', async () => { + const app = createA2AExpressApp({ agentCard: testAgentCard, executor: echoExecutor }); + const res = await request(app) + .post('/') + .send({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/pushNotification/get', + params: { taskId: 'task-1' }, + }); + expect(res.status).toBe(200); + expect(res.body.error).toBeDefined(); + expect(res.body.error.message).toContain('not supported'); + }); + + it('tasks/pushNotification/list throws when not supported', async () => { + const app = createA2AExpressApp({ agentCard: testAgentCard, executor: echoExecutor }); + const res = await request(app).post('/').send({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/pushNotification/list', + params: {}, + }); + expect(res.status).toBe(200); + expect(res.body.error).toBeDefined(); + expect(res.body.error.message).toContain('not supported'); + }); + + it('tasks/pushNotification/delete throws when not supported', async () => { + const app = createA2AExpressApp({ agentCard: testAgentCard, executor: echoExecutor }); + const res = await request(app) + .post('/') + .send({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/pushNotification/delete', + params: { taskId: 'task-1' }, + }); + expect(res.status).toBe(200); + expect(res.body.error).toBeDefined(); + expect(res.body.error.message).toContain('not supported'); + }); + }); + + describe('JSON-RPC tasks/sendSubscribe auto-complete', () => { + it('transitions to completed when executor does not set final state in RPC', async () => { + const noFinalStateExecutor: AgentExecutor = { + async execute(_ctx: ExecutionContext, eventBus: ExecutionEventBus) { + await eventBus.emitStatusUpdate({ kind: 'status', status: { state: 'working' } }); + }, + }; + const app = createA2AExpressApp({ agentCard: testAgentCard, executor: noFinalStateExecutor }); + + const res = await request(app) + .post('/') + .send({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/sendSubscribe', + params: { + message: { + messageId: 'msg-1', + role: 'user', + parts: [{ kind: 'text', text: 'Hello' }], + }, + }, + }); + expect(res.status).toBe(200); + expect(res.body.result.id).toBeDefined(); + expect(res.body.result.status.state).toBe('submitted'); + }); + }); + + describe('extended agent card RPC', () => { + it('tasks/extendedAgentCard returns extended card when configured', async () => { + const app = createA2AExpressApp({ + agentCard: testAgentCard, + executor: echoExecutor, + extendedAgentCard: { description: 'extended info' }, + }); + const res = await request(app).post('/').send({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/extendedAgentCard', + params: {}, + }); + expect(res.status).toBe(200); + expect(res.body.result.description).toBe('extended info'); + }); + + it('tasks/extendedAgentCard throws when not configured', async () => { + const app = createA2AExpressApp({ agentCard: testAgentCard, executor: echoExecutor }); + const res = await request(app).post('/').send({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/extendedAgentCard', + params: {}, + }); + expect(res.status).toBe(200); + expect(res.body.error).toBeDefined(); + expect(res.body.error.message).toContain('not configured'); + }); + }); }); diff --git a/packages/server/src/express.ts b/packages/server/src/express.ts index 149ced3..0a9d25f 100644 --- a/packages/server/src/express.ts +++ b/packages/server/src/express.ts @@ -2,17 +2,25 @@ import type { AuthResult, AuthStrategy } from '@reaatech/a2a-reference-auth'; import type { AgentCard, Task } from '@reaatech/a2a-reference-core'; import { CancelTaskRequestSchema, + ExtendedAgentCardNotConfiguredError, + GetExtendedAgentCardRequestSchema, GetTaskRequestSchema, ListTasksRequestSchema, + PushNotificationNotSupportedError, SendMessageRequestSchema, TaskNotCancelableError, TaskNotFoundError, + TaskPushNotificationConfigSchema, } from '@reaatech/a2a-reference-core'; +import { defaultLogger } from '@reaatech/a2a-reference-observability'; import { InMemoryTaskStore, type TaskStore } from '@reaatech/a2a-reference-persistence'; import express, { type Request, type Response, type Router } from 'express'; import { z } from 'zod'; import type { AgentExecutor } from './executor.js'; +import { type HealthCheck, createHealthStatus } from './health.js'; import { JsonRpcRouter } from './json-rpc.js'; +import { PushNotificationManager } from './push-notifications.js'; +import type { RateLimiter } from './rate-limiter.js'; import { createEventBus, enforcePrincipal, filterByPrincipal, generateTaskId } from './shared.js'; export interface A2AServerOptions { @@ -20,6 +28,18 @@ export interface A2AServerOptions { executor: AgentExecutor; taskStore?: TaskStore; authStrategy?: AuthStrategy; + rateLimiter?: RateLimiter; + extendedAgentCard?: Record; + pushNotificationManager?: PushNotificationManager; + healthChecks?: HealthCheck[]; + version?: string; + /** + * When `true`, derive the client IP for rate limiting from forwarding headers + * (`X-Forwarded-For`). Only enable this behind a trusted proxy that overwrites + * the header — otherwise clients can spoof it to evade or poison rate limits. + * Defaults to `false` (uses the socket peer address). + */ + trustProxyHeaders?: boolean; } export interface A2AServerShutdownOptions { @@ -32,24 +52,66 @@ function getAuth(req: Request): AuthResult | undefined { return authMap.get(req); } +function getHeaders(req: Request): Record { + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + if (value !== undefined) { + headers[key] = value; + } + } + return headers; +} + +function getClientIp(req: Request, trustProxy: boolean): string { + if (trustProxy) { + const forwarded = req.headers['x-forwarded-for']; + if (typeof forwarded === 'string') return forwarded.split(',')[0].trim(); + if (Array.isArray(forwarded)) return forwarded[0].trim(); + } + return req.ip ?? req.socket.remoteAddress ?? 'unknown'; +} + export function createA2AExpressApp( options: A2AServerOptions, ): express.Express & { shutdown: (opts?: A2AServerShutdownOptions) => Promise } { const app = express(); app.use(express.json()); + + app.use( + ( + err: Error & { type?: string }, + _req: express.Request, + res: express.Response, + next: express.NextFunction, + ) => { + if (err.type === 'entity.parse.failed' || err instanceof SyntaxError) { + res + .status(400) + .json({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }); + return; + } + next(err); + }, + ); + const router = createA2ARouter(options); app.use(router); + app.use( + (_err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + defaultLogger.error({ err: _err }, 'Unhandled server error'); + res.status(500).json({ error: 'Internal Server Error' }); + }, + ); + const shutdown = async (opts?: A2AServerShutdownOptions) => { const timeoutMs = opts?.timeoutMs ?? 10_000; const deadline = Date.now() + timeoutMs; - // Close all SSE connections gracefully if ('shutdownSse' in router && typeof router.shutdownSse === 'function') { await router.shutdownSse(); } - // Give in-flight tasks a moment to complete const remaining = deadline - Date.now(); if (remaining > 0) { await new Promise((resolve) => setTimeout(resolve, Math.min(remaining, 1000))); @@ -59,6 +121,8 @@ export function createA2AExpressApp( return Object.assign(app, { shutdown }); } +const startTime = Date.now(); + export function createA2ARouter( options: A2AServerOptions, ): Router & { shutdownSse: () => Promise } { @@ -67,12 +131,32 @@ export function createA2ARouter( const router = express.Router(); const rpc = new JsonRpcRouter(); + const pushNotificationManager = options.pushNotificationManager ?? new PushNotificationManager(); + const pushNotificationsSupported = agentCard.capabilities.pushNotifications ?? false; + + // Rate limiting middleware + const rateLimiter = options.rateLimiter; + if (rateLimiter) { + const trustProxy = options.trustProxyHeaders ?? false; + router.use((req: Request, res: Response, next) => { + const result = rateLimiter.check({ ip: getClientIp(req, trustProxy), headers: req.headers }); + if (!result.allowed) { + const retryAfterSeconds = Math.max(0, Math.ceil((result.resetAt - Date.now()) / 1000)); + res.setHeader('Retry-After', retryAfterSeconds.toString()); + res.status(429).json({ error: 'Too Many Requests', retryAfter: retryAfterSeconds }); + return; + } + res.setHeader('X-RateLimit-Remaining', result.remaining.toString()); + next(); + }); + } + // Authentication middleware const authStrategy = options.authStrategy; if (authStrategy) { router.use(async (req: Request, res: Response, next) => { const result = await authStrategy.authenticate({ - headers: Object.fromEntries(Object.entries(req.headers)), + headers: getHeaders(req), }); if (!result.authenticated) { res.status(401).json({ error: 'Unauthorized' }); @@ -86,8 +170,26 @@ export function createA2ARouter( // Track active SSE connections per task const sseConnections = new Map>(); + function addSseConnection(taskId: string, res: Response): void { + let connections = sseConnections.get(taskId); + if (!connections) { + connections = new Set(); + sseConnections.set(taskId, connections); + } + connections.add(res); + } + + function removeSseConnection(taskId: string, res: Response): void { + const connections = sseConnections.get(taskId); + if (!connections) return; + connections.delete(res); + if (connections.size === 0) { + sseConnections.delete(taskId); + } + } + async function shutdownSse(): Promise { - for (const [taskId, connections] of sseConnections) { + for (const [, connections] of sseConnections) { for (const res of connections) { try { res.end(); @@ -95,8 +197,8 @@ export function createA2ARouter( /* ignore close errors */ } } - sseConnections.delete(taskId); } + sseConnections.clear(); } function broadcastToTask(taskId: string, data: unknown): void { @@ -104,10 +206,41 @@ export function createA2ARouter( if (!connections) return; const payload = `data: ${JSON.stringify(data)}\n\n`; for (const res of connections) { - res.write(payload); + if (res.writableEnded) { + removeSseConnection(taskId, res); + continue; + } + try { + res.write(payload); + } catch { + removeSseConnection(taskId, res); + } } } + // Health check endpoints + router.get('/healthz', async (_req: Request, res: Response) => { + const status = await createHealthStatus({ + taskStore, + version: options.version ?? agentCard.version, + startTime, + checks: options.healthChecks, + }); + const httpStatus = status.status === 'ok' ? 200 : status.status === 'degraded' ? 200 : 503; + res.status(httpStatus).json(status); + }); + + router.get('/readyz', async (_req: Request, res: Response) => { + const status = await createHealthStatus({ + taskStore, + version: options.version ?? agentCard.version, + startTime, + checks: options.healthChecks, + }); + const httpStatus = status.status === 'unhealthy' ? 503 : 200; + res.status(httpStatus).json(status); + }); + // Agent Card discovery router.get('/.well-known/agent.json', (_req: Request, res: Response) => { res.json(agentCard); @@ -117,10 +250,24 @@ export function createA2ARouter( res.json(agentCard); }); + // Extended Agent Card + router.get('/.well-known/agent-card/extended', (_req: Request, res: Response) => { + if (!options.extendedAgentCard) { + res.status(404).json({ error: 'Extended agent card not configured' }); + return; + } + res.json(options.extendedAgentCard); + }); + // JSON-RPC endpoint router.post('/', async (req: Request, res: Response) => { - const response = await rpc.handle(req.body, getAuth(req)); - res.json(response); + try { + const response = await rpc.handle(req.body, getAuth(req)); + res.json(response); + } catch (err) { + defaultLogger.error({ err }, 'rpc.handle() threw unexpected error'); + res.json({ jsonrpc: '2.0', id: null, error: { code: -32603, message: 'Internal error' } }); + } }); // SSE streaming endpoint for tasks/sendSubscribe @@ -128,9 +275,10 @@ export function createA2ARouter( let validated: z.infer; try { validated = SendMessageRequestSchema.parse(req.body); - } catch (err) { - const message = err instanceof Error ? err.message : 'Invalid request body'; - res.status(400).json({ error: 'InvalidParams', message }); + } catch { + res + .status(400) + .json({ jsonrpc: '2.0', id: null, error: { code: -32602, message: 'Invalid params' } }); return; } const message = validated.message; @@ -152,15 +300,11 @@ export function createA2ARouter( res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); - const connections = sseConnections.get(taskId) ?? new Set(); - connections.add(res); - sseConnections.set(taskId, connections); + addSseConnection(taskId, res); req.on('close', () => { - connections.delete(res); - if (connections.size === 0) { - sseConnections.delete(taskId); - } + removeSseConnection(taskId, res); + authMap.delete(req); }); // Send initial task @@ -175,17 +319,18 @@ export function createA2ARouter( try { await executor.execute( { task, message }, - createEventBus(taskId, taskStore, broadcastToTask), + createEventBus(taskId, taskStore, broadcastToTask, pushNotificationManager), ); const finalTask = await taskStore.get(taskId); if (finalTask && finalTask.status.state === 'working') { - await taskStore.updateStatus(taskId, { - state: 'completed', + const statusUpdate = { + state: 'completed' as const, timestamp: new Date().toISOString(), - }); + }; + await taskStore.updateStatus(taskId, statusUpdate); broadcastToTask(taskId, { kind: 'status', - status: { state: 'completed' }, + status: statusUpdate, final: true, }); } @@ -200,7 +345,6 @@ export function createA2ARouter( final: true, }); } finally { - // Give SSE clients a moment to receive final events before closing setTimeout(() => { res.end(); }, 500); @@ -233,18 +377,13 @@ export function createA2ARouter( res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); - const connections = sseConnections.get(taskId) ?? new Set(); - connections.add(res); - sseConnections.set(taskId, connections); + addSseConnection(taskId, res); req.on('close', () => { - connections.delete(res); - if (connections.size === 0) { - sseConnections.delete(taskId); - } + removeSseConnection(taskId, res); + authMap.delete(req); }); - // Send current task state res.write(`data: ${JSON.stringify({ kind: 'task', task })}\n\n`); }); @@ -264,15 +403,14 @@ export function createA2ARouter( }; await taskStore.create(task); - // Transition to working and execute await taskStore.updateStatus(taskId, { state: 'working', timestamp: new Date().toISOString() }); - // Execute asynchronously (async () => { try { + const updatedTask = await taskStore.get(taskId); await executor.execute( - { task, message }, - createEventBus(taskId, taskStore, broadcastToTask), + { task: updatedTask ?? task, message }, + createEventBus(taskId, taskStore, broadcastToTask, pushNotificationManager), ); const finalTask = await taskStore.get(taskId); if (finalTask && finalTask.status.state === 'working') { @@ -311,14 +449,18 @@ export function createA2ARouter( const result = await taskStore.list({ contextId: validated.contextId, status: validated.status, + // Scope the query (and thus totalSize/pagination) to the caller's principal. + principal: auth?.principal, pageSize: validated.pageSize, pageToken: validated.pageToken, }); + // Defense-in-depth: the store already filtered by principal, so this is a no-op + // for principal-scoped stores but guards any store that ignores the option. const filtered = filterByPrincipal(result.tasks, auth); return { tasks: filtered, nextPageToken: result.nextPageToken, - totalSize: filtered.length, + totalSize: result.totalSize, }; }); @@ -338,7 +480,10 @@ export function createA2ARouter( } if (executor.cancelTask) { - await executor.cancelTask(task.id, createEventBus(task.id, taskStore, broadcastToTask)); + await executor.cancelTask( + task.id, + createEventBus(task.id, taskStore, broadcastToTask, pushNotificationManager), + ); } const updated = await taskStore.cancel(task.id); @@ -350,5 +495,94 @@ export function createA2ARouter( return updated; }); + // tasks/sendSubscribe (JSON-RPC style) + rpc.register('tasks/sendSubscribe', async (params, auth) => { + const validated = SendMessageRequestSchema.parse(params); + const message = validated.message; + const taskId = validated.taskId || generateTaskId(); + + const task: Task = { + id: taskId, + contextId: validated.contextId, + status: { state: 'submitted', timestamp: new Date().toISOString() }, + history: [message], + metadata: {}, + principal: auth?.principal, + }; + await taskStore.create(task); + await taskStore.updateStatus(taskId, { state: 'working', timestamp: new Date().toISOString() }); + + (async () => { + try { + await executor.execute( + { task, message }, + createEventBus(taskId, taskStore, broadcastToTask, pushNotificationManager), + ); + const finalTask = await taskStore.get(taskId); + if (finalTask && finalTask.status.state === 'working') { + await taskStore.updateStatus(taskId, { + state: 'completed', + timestamp: new Date().toISOString(), + }); + } + } catch { + await taskStore.updateStatus(taskId, { + state: 'failed', + timestamp: new Date().toISOString(), + }); + } + })(); + + return task; + }); + + // Push notification config management + rpc.register('tasks/pushNotification/set', async (params, _auth) => { + if (!pushNotificationsSupported) { + throw new PushNotificationNotSupportedError(); + } + const config = TaskPushNotificationConfigSchema.parse(params); + pushNotificationManager.register(config); + return { ok: true, id: config.id }; + }); + + rpc.register('tasks/pushNotification/get', async (params, _auth) => { + if (!pushNotificationsSupported) { + throw new PushNotificationNotSupportedError(); + } + const { taskId } = z.object({ taskId: z.string() }).parse(params); + const config = pushNotificationManager.getConfig(taskId); + if (!config) { + throw new TaskNotFoundError(taskId); + } + return config; + }); + + rpc.register('tasks/pushNotification/list', async (params, _auth) => { + if (!pushNotificationsSupported) { + throw new PushNotificationNotSupportedError(); + } + const { taskId } = z.object({ taskId: z.string().optional() }).parse(params); + return { configs: pushNotificationManager.listConfigs(taskId) }; + }); + + rpc.register('tasks/pushNotification/delete', async (params, _auth) => { + if (!pushNotificationsSupported) { + throw new PushNotificationNotSupportedError(); + } + const { taskId } = z.object({ taskId: z.string() }).parse(params); + pushNotificationManager.unregister(taskId); + return { ok: true }; + }); + + // Extended agent card + rpc.register('tasks/extendedAgentCard', async (params) => { + if (!options.extendedAgentCard) { + throw new ExtendedAgentCardNotConfiguredError(); + } + GetExtendedAgentCardRequestSchema.parse(params); + return options.extendedAgentCard; + }); + return Object.assign(router, { shutdownSse }); } diff --git a/packages/server/src/health.test.ts b/packages/server/src/health.test.ts new file mode 100644 index 0000000..8572698 --- /dev/null +++ b/packages/server/src/health.test.ts @@ -0,0 +1,272 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createHealthStatus } from './health.js'; + +describe('createHealthStatus', () => { + it('returns ok when taskStore responds with a valid list', async () => { + const mockStore = { + list: vi.fn().mockResolvedValue({ tasks: [], nextPageToken: '', totalSize: 0 }), + create: vi.fn(), + get: vi.fn(), + update: vi.fn(), + cancel: vi.fn(), + addHistory: vi.fn(), + addArtifact: vi.fn(), + updateStatus: vi.fn(), + }; + + const result = await createHealthStatus({ taskStore: mockStore }); + + expect(result.status).toBe('ok'); + expect(result.checks).toHaveLength(1); + expect(result.checks[0]).toMatchObject({ name: 'task-store', status: 'ok' }); + }); + + it('returns degraded when taskStore returns unexpected response', async () => { + const mockStore = { + list: vi.fn().mockResolvedValue({ tasks: null }), + create: vi.fn(), + get: vi.fn(), + update: vi.fn(), + cancel: vi.fn(), + addHistory: vi.fn(), + addArtifact: vi.fn(), + updateStatus: vi.fn(), + }; + + const result = await createHealthStatus({ taskStore: mockStore }); + + expect(result.status).toBe('degraded'); + expect(result.checks).toHaveLength(1); + expect(result.checks[0]).toMatchObject({ + name: 'task-store', + status: 'degraded', + message: 'unexpected response', + }); + }); + + it('returns degraded when taskStore returns a falsy result', async () => { + const mockStore = { + list: vi.fn().mockResolvedValue(undefined), + create: vi.fn(), + get: vi.fn(), + update: vi.fn(), + cancel: vi.fn(), + addHistory: vi.fn(), + addArtifact: vi.fn(), + updateStatus: vi.fn(), + }; + + const result = await createHealthStatus({ taskStore: mockStore }); + + expect(result.status).toBe('degraded'); + expect(result.checks[0]).toMatchObject({ name: 'task-store', status: 'degraded' }); + }); + + it('returns degraded when taskStore returns empty object without tasks', async () => { + const mockStore = { + list: vi.fn().mockResolvedValue({}), + create: vi.fn(), + get: vi.fn(), + update: vi.fn(), + cancel: vi.fn(), + addHistory: vi.fn(), + addArtifact: vi.fn(), + updateStatus: vi.fn(), + }; + + const result = await createHealthStatus({ taskStore: mockStore }); + + expect(result.status).toBe('degraded'); + expect(result.checks[0]).toMatchObject({ name: 'task-store', status: 'degraded' }); + }); + + it('returns unhealthy when taskStore throws', async () => { + const mockStore = { + list: vi.fn().mockRejectedValue(new Error('connection refused')), + create: vi.fn(), + get: vi.fn(), + update: vi.fn(), + cancel: vi.fn(), + addHistory: vi.fn(), + addArtifact: vi.fn(), + updateStatus: vi.fn(), + }; + + const result = await createHealthStatus({ taskStore: mockStore }); + + expect(result.status).toBe('unhealthy'); + expect(result.checks).toHaveLength(1); + expect(result.checks[0]).toMatchObject({ + name: 'task-store', + status: 'unhealthy', + message: 'connection refused', + }); + }); + + it('includes error message when taskStore throws a non-Error', async () => { + const mockStore = { + list: vi.fn().mockRejectedValue('something broke'), + create: vi.fn(), + get: vi.fn(), + update: vi.fn(), + cancel: vi.fn(), + addHistory: vi.fn(), + addArtifact: vi.fn(), + updateStatus: vi.fn(), + }; + + const result = await createHealthStatus({ taskStore: mockStore }); + + expect(result.status).toBe('unhealthy'); + expect(result.checks[0].message).toBe('unknown error'); + }); + + it('skips taskStore check when no taskStore is provided', async () => { + const result = await createHealthStatus({}); + + expect(result.checks).toHaveLength(0); + expect(result.status).toBe('ok'); + }); + + it('respects custom checks that return ok', async () => { + const mockStore = { + list: vi.fn().mockResolvedValue({ tasks: [], nextPageToken: '', totalSize: 0 }), + create: vi.fn(), + get: vi.fn(), + update: vi.fn(), + cancel: vi.fn(), + addHistory: vi.fn(), + addArtifact: vi.fn(), + updateStatus: vi.fn(), + }; + + const result = await createHealthStatus({ + taskStore: mockStore, + checks: [ + { + name: 'db-connections', + check: async () => ({ status: 'ok' as const }), + }, + ], + }); + + expect(result.checks).toHaveLength(2); + expect(result.checks[1]).toMatchObject({ name: 'db-connections', status: 'ok' }); + expect(result.status).toBe('ok'); + }); + + it('custom checks can elevate status to degraded', async () => { + const mockStore = { + list: vi.fn().mockResolvedValue({ tasks: [], nextPageToken: '', totalSize: 0 }), + create: vi.fn(), + get: vi.fn(), + update: vi.fn(), + cancel: vi.fn(), + addHistory: vi.fn(), + addArtifact: vi.fn(), + updateStatus: vi.fn(), + }; + + const result = await createHealthStatus({ + taskStore: mockStore, + checks: [ + { + name: 'slow-db', + check: async () => ({ status: 'degraded' as const, message: 'high latency' }), + }, + ], + }); + + expect(result.status).toBe('degraded'); + expect(result.checks[1]).toMatchObject({ + name: 'slow-db', + status: 'degraded', + message: 'high latency', + }); + }); + + it('custom checks can elevate status to unhealthy', async () => { + const mockStore = { + list: vi.fn().mockResolvedValue({ tasks: [], nextPageToken: '', totalSize: 0 }), + create: vi.fn(), + get: vi.fn(), + update: vi.fn(), + cancel: vi.fn(), + addHistory: vi.fn(), + addArtifact: vi.fn(), + updateStatus: vi.fn(), + }; + + const result = await createHealthStatus({ + taskStore: mockStore, + checks: [ + { + name: 'critical-service', + check: async () => ({ status: 'unhealthy' as const, message: 'down' }), + }, + ], + }); + + expect(result.status).toBe('unhealthy'); + expect(result.checks[1]).toMatchObject({ + name: 'critical-service', + status: 'unhealthy', + message: 'down', + }); + }); + + it('catches exceptions thrown by custom checks', async () => { + const mockStore = { + list: vi.fn().mockResolvedValue({ tasks: [], nextPageToken: '', totalSize: 0 }), + create: vi.fn(), + get: vi.fn(), + update: vi.fn(), + cancel: vi.fn(), + addHistory: vi.fn(), + addArtifact: vi.fn(), + updateStatus: vi.fn(), + }; + + const result = await createHealthStatus({ + taskStore: mockStore, + checks: [ + { + name: 'flaky-service', + check: async () => { + throw new Error('timeout'); + }, + }, + ], + }); + + expect(result.checks[1]).toMatchObject({ + name: 'flaky-service', + status: 'unhealthy', + message: 'timeout', + }); + expect(result.status).toBe('unhealthy'); + }); + + it('includes version and uptime fields', async () => { + const startTime = Date.now(); + const result = await createHealthStatus({ version: '1.2.3', startTime }); + + expect(result.version).toBe('1.2.3'); + expect(typeof result.uptime).toBe('number'); + expect(result.uptime).toBeGreaterThanOrEqual(0); + expect(result.uptime).toBeLessThan(10); + }); + + it('falls back to default version 0.0.0', async () => { + const result = await createHealthStatus({}); + + expect(result.version).toBe('0.0.0'); + }); + + it('includes an ISO timestamp string', async () => { + const result = await createHealthStatus({}); + + expect(result.timestamp).toBeDefined(); + expect(() => new Date(result.timestamp)).not.toThrow(); + }); +}); diff --git a/packages/server/src/health.ts b/packages/server/src/health.ts new file mode 100644 index 0000000..4284005 --- /dev/null +++ b/packages/server/src/health.ts @@ -0,0 +1,94 @@ +import { defaultLogger } from '@reaatech/a2a-reference-observability'; +import type { TaskStore } from '@reaatech/a2a-reference-persistence'; +import { z } from 'zod'; + +export const HealthStatusSchema = z.object({ + status: z.enum(['ok', 'degraded', 'unhealthy']), + version: z.string(), + uptime: z.number(), + timestamp: z.string(), + checks: z.array( + z.object({ + name: z.string(), + status: z.enum(['ok', 'degraded', 'unhealthy']), + message: z.string().optional(), + }), + ), +}); + +export interface HealthStatus { + status: 'ok' | 'degraded' | 'unhealthy'; + version: string; + uptime: number; + timestamp: string; + checks: { + name: string; + status: 'ok' | 'degraded' | 'unhealthy'; + message?: string; + }[]; +} + +export interface HealthCheckDependencies { + taskStore?: TaskStore; + version?: string; + startTime?: number; + checks?: HealthCheck[]; +} + +export interface HealthCheck { + name: string; + check: () => Promise<{ status: 'ok' | 'degraded' | 'unhealthy'; message?: string }>; +} + +export async function createHealthStatus(deps: HealthCheckDependencies): Promise { + const checks: HealthStatus['checks'] = []; + + if (deps.taskStore) { + try { + const result = await deps.taskStore.list({ pageSize: 1 }); + if (result && Array.isArray(result.tasks)) { + checks.push({ name: 'task-store', status: 'ok' }); + } else { + checks.push({ name: 'task-store', status: 'degraded', message: 'unexpected response' }); + } + } catch (err) { + checks.push({ + name: 'task-store', + status: 'unhealthy', + message: err instanceof Error ? err.message : 'unknown error', + }); + } + } + + if (deps.checks) { + for (const customCheck of deps.checks) { + try { + const result = await customCheck.check(); + checks.push({ name: customCheck.name, status: result.status, message: result.message }); + } catch (err) { + checks.push({ + name: customCheck.name, + status: 'unhealthy', + message: err instanceof Error ? err.message : 'check threw', + }); + } + } + } + + const hasUnhealthy = checks.some((c) => c.status === 'unhealthy'); + const hasDegraded = checks.some((c) => c.status === 'degraded'); + + const healthStatus: HealthStatus = { + status: hasUnhealthy ? 'unhealthy' : hasDegraded ? 'degraded' : 'ok', + version: deps.version ?? '0.0.0', + uptime: Math.floor((Date.now() - (deps.startTime ?? Date.now())) / 1000), + timestamp: new Date().toISOString(), + checks, + }; + + const validated = HealthStatusSchema.safeParse(healthStatus); + if (!validated.success) { + defaultLogger.error({ err: validated.error }, 'health status validation failed'); + } + return validated.success ? validated.data : healthStatus; +} diff --git a/packages/server/src/hono.test.ts b/packages/server/src/hono.test.ts index 8267bab..39cb900 100644 --- a/packages/server/src/hono.test.ts +++ b/packages/server/src/hono.test.ts @@ -3,6 +3,7 @@ import { InMemoryTaskStore } from '@reaatech/a2a-reference-persistence'; import { describe, expect, it, vi } from 'vitest'; import type { AgentExecutor } from './executor.js'; import { createA2AHonoApp } from './hono.js'; +import { RateLimiter } from './rate-limiter.js'; const testAgentCard = { name: 'Hono Test Agent', @@ -468,4 +469,651 @@ describe('createA2AHonoApp', () => { const res = await ssePromise; expect(res.status).toBe(200); }); + + describe('health check endpoints', () => { + it('GET /healthz returns 200 with ok status', async () => { + const app = createA2AHonoApp({ agentCard: testAgentCard, executor: testExecutor }); + const res = await app.request('/healthz'); + expect(res.status).toBe(200); + const body = (await res.json()) as { status: string }; + expect(body.status).toBe('ok'); + }); + + it('GET /readyz returns 200 when healthy', async () => { + const app = createA2AHonoApp({ agentCard: testAgentCard, executor: testExecutor }); + const res = await app.request('/readyz'); + expect(res.status).toBe(200); + const body = (await res.json()) as { status: string }; + expect(body.status).toBe('ok'); + }); + + it('health endpoint includes version and uptime', async () => { + const app = createA2AHonoApp({ agentCard: testAgentCard, executor: testExecutor }); + const res = await app.request('/healthz'); + const body = (await res.json()) as { version: string; uptime: number; timestamp: string }; + expect(body.version).toBe('1.0.0'); + expect(typeof body.uptime).toBe('number'); + expect(body.timestamp).toBeDefined(); + }); + }); + + describe('rate limiting', () => { + it('requests under limit succeed', async () => { + const limiter = new RateLimiter({ windowMs: 60000, maxRequests: 10 }); + const app = createA2AHonoApp({ + agentCard: testAgentCard, + executor: testExecutor, + rateLimiter: limiter, + }); + const res = await app.request('/.well-known/agent.json'); + expect(res.status).toBe(200); + }); + + it('requests over limit get 429', async () => { + const limiter = new RateLimiter({ windowMs: 60000, maxRequests: 1 }); + const app = createA2AHonoApp({ + agentCard: testAgentCard, + executor: testExecutor, + rateLimiter: limiter, + }); + await app.request('/.well-known/agent.json'); + const res = await app.request('/.well-known/agent.json'); + expect(res.status).toBe(429); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe('Too Many Requests'); + }); + + it('X-RateLimit-Remaining header is present', async () => { + const limiter = new RateLimiter({ windowMs: 60000, maxRequests: 10 }); + const app = createA2AHonoApp({ + agentCard: testAgentCard, + executor: testExecutor, + rateLimiter: limiter, + }); + const res = await app.request('/.well-known/agent.json'); + expect(res.headers.get('X-RateLimit-Remaining')).not.toBeNull(); + expect(Number(res.headers.get('X-RateLimit-Remaining'))).toBeGreaterThanOrEqual(0); + }); + }); + + describe('extended agent card', () => { + it('GET /.well-known/agent-card/extended returns extended card when configured', async () => { + const app = createA2AHonoApp({ + agentCard: testAgentCard, + executor: testExecutor, + extendedAgentCard: { description: 'extended info', customField: 'value' }, + }); + const res = await app.request('/.well-known/agent-card/extended'); + expect(res.status).toBe(200); + const body = (await res.json()) as { description: string; customField: string }; + expect(body.description).toBe('extended info'); + expect(body.customField).toBe('value'); + }); + + it('GET /.well-known/agent-card/extended returns 404 when not configured', async () => { + const app = createA2AHonoApp({ agentCard: testAgentCard, executor: testExecutor }); + const res = await app.request('/.well-known/agent-card/extended'); + expect(res.status).toBe(404); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe('Extended agent card not configured'); + }); + }); + + describe('push notification RPCs', () => { + const pushAgentCard = { + ...testAgentCard, + name: 'Push Agent', + capabilities: { ...testAgentCard.capabilities, pushNotifications: true }, + }; + + it('tasks/pushNotification/set registers config', async () => { + const app = createA2AHonoApp({ agentCard: pushAgentCard, executor: testExecutor }); + const res = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/pushNotification/set', + params: { id: 'cfg-1', taskId: 'task-1', url: 'https://example.com/callback' }, + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { result: { ok: boolean; id: string } }; + expect(body.result.ok).toBe(true); + expect(body.result.id).toBe('cfg-1'); + }); + + it('tasks/pushNotification/get returns config', async () => { + const app = createA2AHonoApp({ agentCard: pushAgentCard, executor: testExecutor }); + await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/pushNotification/set', + params: { id: 'cfg-2', taskId: 'task-2', url: 'https://example.com/callback' }, + }), + }); + const res = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tasks/pushNotification/get', + params: { taskId: 'task-2' }, + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { result: { taskId: string; url: string } }; + expect(body.result.taskId).toBe('task-2'); + expect(body.result.url).toBe('https://example.com/callback'); + }); + + it('tasks/pushNotification/list returns configs', async () => { + const app = createA2AHonoApp({ agentCard: pushAgentCard, executor: testExecutor }); + await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/pushNotification/set', + params: { id: 'cfg-3', taskId: 'task-3', url: 'https://example.com/callback' }, + }), + }); + const res = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tasks/pushNotification/list', + params: {}, + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { result: { configs: unknown[] } }; + expect(Array.isArray(body.result.configs)).toBe(true); + expect(body.result.configs).toHaveLength(1); + }); + + it('tasks/pushNotification/delete removes config', async () => { + const app = createA2AHonoApp({ agentCard: pushAgentCard, executor: testExecutor }); + await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/pushNotification/set', + params: { id: 'cfg-4', taskId: 'task-4', url: 'https://example.com/callback' }, + }), + }); + await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tasks/pushNotification/delete', + params: { taskId: 'task-4' }, + }), + }); + const getRes = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 3, + method: 'tasks/pushNotification/get', + params: { taskId: 'task-4' }, + }), + }); + expect(getRes.status).toBe(200); + const getBody = (await getRes.json()) as { error?: { message: string } }; + expect(getBody.error).toBeDefined(); + expect(getBody.error?.message).toContain('not found'); + }); + }); + + describe('SSE subscribe endpoint', () => { + it('returns 404 when task belongs to different principal', async () => { + const taskStore = new InMemoryTaskStore(); + await taskStore.create({ + id: 'other-task', + status: { state: 'submitted', timestamp: new Date().toISOString() }, + history: [], + metadata: {}, + principal: 'other-user', + }); + const app = createA2AHonoApp({ + agentCard: testAgentCard, + executor: testExecutor, + taskStore, + authStrategy: new NoneStrategy(), + }); + const res = await app.request('/tasks/other-task/subscribe'); + expect(res.status).toBe(404); + const body = (await res.json()) as { error: string }; + expect(body.error).toContain('not found'); + }); + + it('returns 400 when task is in terminal state', async () => { + const taskStore = new InMemoryTaskStore(); + await taskStore.create({ + id: 'done-task', + status: { state: 'completed', timestamp: new Date().toISOString() }, + history: [], + metadata: {}, + }); + const app = createA2AHonoApp({ + agentCard: testAgentCard, + executor: testExecutor, + taskStore, + }); + const res = await app.request('/tasks/done-task/subscribe'); + expect(res.status).toBe(400); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe('Task is in a terminal state'); + }); + }); + + describe('onError handler', () => { + it('returns 500 on unhandled errors', async () => { + const store = new InMemoryTaskStore(); + store.get = async () => { + throw new Error('store error'); + }; + + const app = createA2AHonoApp({ + agentCard: testAgentCard, + executor: testExecutor, + taskStore: store, + }); + const res = await app.request('/tasks/any-task/subscribe'); + expect(res.status).toBe(500); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe('Internal Server Error'); + }); + }); + + describe('SSE endpoint executor behavior', () => { + it('handles executor failure in SSE endpoint', async () => { + const throwingExecutor: AgentExecutor = { + async execute() { + throw new Error('SSE failed'); + }, + }; + const app = createA2AHonoApp({ agentCard: testAgentCard, executor: throwingExecutor }); + const res = await app.request('/tasks/sendSubscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: { + messageId: 'msg-1', + role: 'user', + parts: [{ kind: 'text', text: 'Hello' }], + }, + }), + }); + expect(res.status).toBe(200); + const text = await res.text(); + expect(text).toContain('"state":"failed"'); + expect(text).toContain('"final":true'); + }); + + it('auto-completes task when executor does not set final state in SSE', async () => { + const noFinalStateExecutor: AgentExecutor = { + async execute(_ctx, bus) { + await bus.emitStatusUpdate({ kind: 'status', status: { state: 'working' } }); + }, + }; + const app = createA2AHonoApp({ agentCard: testAgentCard, executor: noFinalStateExecutor }); + const res = await app.request('/tasks/sendSubscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: { + messageId: 'msg-1', + role: 'user', + parts: [{ kind: 'text', text: 'Hello' }], + }, + }), + }); + expect(res.status).toBe(200); + const text = await res.text(); + expect(text).toContain('"state":"completed"'); + expect(text).toContain('"final":true'); + }); + }); + + describe('tasks/send executor behavior', () => { + it('handles executor failure in tasks/send', async () => { + const throwingExecutor: AgentExecutor = { + async execute() { + throw new Error('send failed'); + }, + }; + const taskStore = new InMemoryTaskStore(); + const app = createA2AHonoApp({ + agentCard: testAgentCard, + executor: throwingExecutor, + taskStore, + }); + const res = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/send', + params: { + message: { + messageId: 'msg-1', + role: 'user', + parts: [{ kind: 'text', text: 'Hello' }], + }, + }, + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { result: { id: string } }; + const taskId = body.result.id; + + await new Promise((r) => setTimeout(r, 100)); + + const getRes = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tasks/get', + params: { id: taskId }, + }), + }); + const getBody = (await getRes.json()) as { + result: { status: { state: string } }; + }; + expect(getBody.result.status.state).toBe('failed'); + }); + + it('transitions to completed when executor does not set final state', async () => { + const noFinalStateExecutor: AgentExecutor = { + async execute(_ctx, bus) { + await bus.emitStatusUpdate({ kind: 'status', status: { state: 'working' } }); + }, + }; + const taskStore = new InMemoryTaskStore(); + const app = createA2AHonoApp({ + agentCard: testAgentCard, + executor: noFinalStateExecutor, + taskStore, + }); + const res = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/send', + params: { + message: { + messageId: 'msg-1', + role: 'user', + parts: [{ kind: 'text', text: 'Hello' }], + }, + }, + }), + }); + const sendBody = (await res.json()) as { result: { id: string } }; + await new Promise((r) => setTimeout(r, 100)); + + const getRes = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tasks/get', + params: { id: sendBody.result.id }, + }), + }); + const getBody = (await getRes.json()) as { + result: { status: { state: string } }; + }; + expect(getBody.result.status.state).toBe('completed'); + }); + }); + + describe('tasks/cancel error paths', () => { + it('returns error when canceling non-existent task', async () => { + const app = createA2AHonoApp({ agentCard: testAgentCard, executor: testExecutor }); + const res = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/cancel', + params: { id: 'non-existent' }, + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { error?: { message: string } }; + expect(body.error).toBeDefined(); + expect(body.error?.message).toContain('not found'); + }); + }); + + describe('JSON-RPC tasks/sendSubscribe auto-complete', () => { + it('transitions to completed when executor does not set final state in RPC', async () => { + const noFinalStateExecutor: AgentExecutor = { + async execute(_ctx, bus) { + await bus.emitStatusUpdate({ kind: 'status', status: { state: 'working' } }); + }, + }; + const app = createA2AHonoApp({ agentCard: testAgentCard, executor: noFinalStateExecutor }); + + const res = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/sendSubscribe', + params: { + message: { + messageId: 'msg-1', + role: 'user', + parts: [{ kind: 'text', text: 'Hello' }], + }, + }, + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { id: string; status: { state: string } }; + }; + expect(body.result.id).toBeDefined(); + expect(body.result.status.state).toBe('submitted'); + }); + }); + + describe('JSON-RPC tasks/sendSubscribe', () => { + it('handles tasks/sendSubscribe via JSON-RPC', async () => { + const app = createA2AHonoApp({ agentCard: testAgentCard, executor: testExecutor }); + const res = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/sendSubscribe', + params: { + message: { + messageId: 'msg-1', + role: 'user', + parts: [{ kind: 'text', text: 'Hello' }], + }, + }, + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { id: string; status: { state: string } }; + }; + expect(body.result.id).toBeDefined(); + expect(body.result.status.state).toBe('submitted'); + }); + + it('handles executor failure in tasks/sendSubscribe RPC', async () => { + const throwingExecutor: AgentExecutor = { + async execute() { + throw new Error('RPC sendSubscribe failed'); + }, + }; + const app = createA2AHonoApp({ agentCard: testAgentCard, executor: throwingExecutor }); + const res = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/sendSubscribe', + params: { + message: { + messageId: 'msg-1', + role: 'user', + parts: [{ kind: 'text', text: 'Hello' }], + }, + }, + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { id: string; status: { state: string } }; + }; + expect(body.result.id).toBeDefined(); + expect(body.result.status.state).toBe('submitted'); + }); + }); + + describe('push notification not-supported', () => { + it('tasks/pushNotification/set throws when not supported', async () => { + const app = createA2AHonoApp({ agentCard: testAgentCard, executor: testExecutor }); + const res = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/pushNotification/set', + params: { id: 'cfg-1', taskId: 'task-1', url: 'https://example.com/callback' }, + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { error?: { message: string } }; + expect(body.error).toBeDefined(); + expect(body.error?.message).toContain('not supported'); + }); + + it('tasks/pushNotification/get throws when not supported', async () => { + const app = createA2AHonoApp({ agentCard: testAgentCard, executor: testExecutor }); + const res = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/pushNotification/get', + params: { taskId: 'task-1' }, + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { error?: { message: string } }; + expect(body.error).toBeDefined(); + expect(body.error?.message).toContain('not supported'); + }); + + it('tasks/pushNotification/list throws when not supported', async () => { + const app = createA2AHonoApp({ agentCard: testAgentCard, executor: testExecutor }); + const res = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/pushNotification/list', + params: {}, + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { error?: { message: string } }; + expect(body.error).toBeDefined(); + expect(body.error?.message).toContain('not supported'); + }); + + it('tasks/pushNotification/delete throws when not supported', async () => { + const app = createA2AHonoApp({ agentCard: testAgentCard, executor: testExecutor }); + const res = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/pushNotification/delete', + params: { taskId: 'task-1' }, + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { error?: { message: string } }; + expect(body.error).toBeDefined(); + expect(body.error?.message).toContain('not supported'); + }); + }); + + describe('extended agent card RPC', () => { + it('tasks/extendedAgentCard returns extended card when configured', async () => { + const app = createA2AHonoApp({ + agentCard: testAgentCard, + executor: testExecutor, + extendedAgentCard: { description: 'extended info' }, + }); + const res = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/extendedAgentCard', + params: {}, + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { result: { description: string } }; + expect(body.result.description).toBe('extended info'); + }); + + it('tasks/extendedAgentCard throws when not configured', async () => { + const app = createA2AHonoApp({ agentCard: testAgentCard, executor: testExecutor }); + const res = await app.request('/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tasks/extendedAgentCard', + params: {}, + }), + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { error?: { message: string } }; + expect(body.error).toBeDefined(); + expect(body.error?.message).toContain('not configured'); + }); + }); }); diff --git a/packages/server/src/hono.ts b/packages/server/src/hono.ts index 69a9b05..7808cc4 100644 --- a/packages/server/src/hono.ts +++ b/packages/server/src/hono.ts @@ -2,17 +2,26 @@ import type { AuthResult, AuthStrategy } from '@reaatech/a2a-reference-auth'; import type { AgentCard, Task } from '@reaatech/a2a-reference-core'; import { CancelTaskRequestSchema, + ExtendedAgentCardNotConfiguredError, + GetExtendedAgentCardRequestSchema, GetTaskRequestSchema, ListTasksRequestSchema, + PushNotificationNotSupportedError, SendMessageRequestSchema, TaskNotCancelableError, TaskNotFoundError, + TaskPushNotificationConfigSchema, } from '@reaatech/a2a-reference-core'; +import { defaultLogger } from '@reaatech/a2a-reference-observability'; import { InMemoryTaskStore, type TaskStore } from '@reaatech/a2a-reference-persistence'; import { Hono } from 'hono'; import type { Context } from 'hono'; +import { z } from 'zod'; import type { AgentExecutor } from './executor.js'; +import { type HealthCheck, createHealthStatus } from './health.js'; import { JsonRpcRouter } from './json-rpc.js'; +import { PushNotificationManager } from './push-notifications.js'; +import type { RateLimiter } from './rate-limiter.js'; import { createEventBus, enforcePrincipal, filterByPrincipal, generateTaskId } from './shared.js'; export interface A2AHonoOptions { @@ -20,6 +29,18 @@ export interface A2AHonoOptions { executor: AgentExecutor; taskStore?: TaskStore; authStrategy?: AuthStrategy; + rateLimiter?: RateLimiter; + extendedAgentCard?: Record; + pushNotificationManager?: PushNotificationManager; + healthChecks?: HealthCheck[]; + version?: string; + /** + * When `true`, derive the client IP for rate limiting from forwarding headers + * (`X-Forwarded-For` / `CF-Connecting-IP` / `X-Real-IP`). Only enable this + * behind a trusted proxy that overwrites the headers — otherwise clients can + * spoof them to evade or poison rate limits. Defaults to `false`. + */ + trustProxyHeaders?: boolean; } export interface A2AHonoShutdownOptions { @@ -45,6 +66,8 @@ function getHeaders(record: Headers): Record & { shutdown: (opts?: A2AHonoShutdownOptions) => Promise } { @@ -53,8 +76,35 @@ export function createA2AHonoApp( const rpc = new JsonRpcRouter(); const sseConnections = new Map>>(); + const pushNotificationManager = options.pushNotificationManager ?? new PushNotificationManager(); + const pushNotificationsSupported = agentCard.capabilities.pushNotifications ?? false; + + function addSseConnection( + taskId: string, + controller: ReadableStreamDefaultController, + ): void { + let connections = sseConnections.get(taskId); + if (!connections) { + connections = new Set(); + sseConnections.set(taskId, connections); + } + connections.add(controller); + } + + function removeSseConnection( + taskId: string, + controller: ReadableStreamDefaultController, + ): void { + const connections = sseConnections.get(taskId); + if (!connections) return; + connections.delete(controller); + if (connections.size === 0) { + sseConnections.delete(taskId); + } + } + async function shutdownSse(): Promise { - for (const [taskId, connections] of sseConnections) { + for (const [, connections] of sseConnections) { for (const controller of connections) { try { controller.close(); @@ -62,8 +112,8 @@ export function createA2AHonoApp( /* ignore close errors */ } } - sseConnections.delete(taskId); } + sseConnections.clear(); } function broadcastToTask(taskId: string, data: unknown): void { @@ -71,12 +121,57 @@ export function createA2AHonoApp( if (!connections) return; const payload = new TextEncoder().encode(`data: ${JSON.stringify(data)}\n\n`); for (const controller of connections) { - controller.enqueue(payload); + try { + controller.enqueue(payload); + } catch { + removeSseConnection(taskId, controller); + } } } const app = new Hono<{ Variables: Variables }>(); + app.onError((err, c) => { + defaultLogger.error( + { error: err instanceof Error ? err.message : String(err) }, + 'Unhandled server error', + ); + return c.json({ error: 'Internal Server Error' }, 500); + }); + + // Rate limiting middleware + const rateLimiter = options.rateLimiter; + if (rateLimiter) { + const trustProxy = options.trustProxyHeaders ?? false; + app.use(async (c, next) => { + let clientIp = 'unknown'; + if (trustProxy) { + const forwarded = c.req.header('x-forwarded-for'); + const cfIp = c.req.header('cf-connecting-ip'); + const realIp = c.req.header('x-real-ip'); + clientIp = forwarded ? forwarded.split(',')[0].trim() : (cfIp ?? realIp ?? 'unknown'); + } else { + // Best-effort peer address from the underlying adapter (e.g. @hono/node-server); + // forwarding headers are ignored unless trustProxyHeaders is enabled. + const env = c.env as { incoming?: { socket?: { remoteAddress?: string } } } | undefined; + clientIp = env?.incoming?.socket?.remoteAddress ?? 'unknown'; + } + const result = rateLimiter.check({ + ip: clientIp, + headers: Object.fromEntries( + Array.from(c.req.raw.headers.entries()).map(([k, v]) => [k, v]), + ), + }); + if (!result.allowed) { + const retryAfterSeconds = Math.max(0, Math.ceil((result.resetAt - Date.now()) / 1000)); + c.res.headers.set('Retry-After', retryAfterSeconds.toString()); + return c.json({ error: 'Too Many Requests', retryAfter: retryAfterSeconds }, 429); + } + c.res.headers.set('X-RateLimit-Remaining', result.remaining.toString()); + return next(); + }); + } + // Authentication middleware const authStrategy = options.authStrategy; if (authStrategy) { @@ -92,6 +187,29 @@ export function createA2AHonoApp( }); } + // Health check endpoints + app.get('/healthz', async (c: Context) => { + const status = await createHealthStatus({ + taskStore, + version: options.version ?? agentCard.version, + startTime, + checks: options.healthChecks, + }); + const httpStatus = status.status === 'ok' ? 200 : status.status === 'degraded' ? 200 : 503; + return c.json(status, httpStatus); + }); + + app.get('/readyz', async (c: Context) => { + const status = await createHealthStatus({ + taskStore, + version: options.version ?? agentCard.version, + startTime, + checks: options.healthChecks, + }); + const httpStatus = status.status === 'unhealthy' ? 503 : 200; + return c.json(status, httpStatus); + }); + // Agent Card discovery app.get('/.well-known/agent.json', (c: Context) => { return c.json(agentCard); @@ -101,18 +219,59 @@ export function createA2AHonoApp( return c.json(agentCard); }); + // Extended Agent Card + app.get('/.well-known/agent-card/extended', (c: Context) => { + if (!options.extendedAgentCard) { + return c.json({ error: 'Extended agent card not configured' }, 404); + } + return c.json(options.extendedAgentCard); + }); + // JSON-RPC endpoint app.post('/', async (c: Context) => { - const body = await c.req.json(); + let body: unknown; + try { + body = await c.req.json(); + } catch { + return c.json( + { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }, + 400, + ); + } const auth = c.get('auth'); - const response = await rpc.handle(body, auth); - return c.json(response); + try { + const response = await rpc.handle(body, auth); + return c.json(response); + } catch (err) { + defaultLogger.error({ err }, 'rpc.handle() threw unexpected error'); + return c.json({ + jsonrpc: '2.0', + id: null, + error: { code: -32603, message: 'Internal error' }, + }); + } }); // SSE streaming endpoint for tasks/sendSubscribe app.post('/tasks/sendSubscribe', async (c: Context) => { - const params = await c.req.json(); - const validated = SendMessageRequestSchema.parse(params); + let params: unknown; + try { + params = await c.req.json(); + } catch { + return c.json( + { jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }, + 400, + ); + } + let validated: z.infer; + try { + validated = SendMessageRequestSchema.parse(params); + } catch { + return c.json( + { jsonrpc: '2.0', id: null, error: { code: -32602, message: 'Invalid params' } }, + 400, + ); + } const message = validated.message; const taskId = validated.taskId || generateTaskId(); const auth = c.get('auth'); @@ -129,9 +288,7 @@ export function createA2AHonoApp( const stream = new ReadableStream({ start(controller) { - const connections = sseConnections.get(taskId) ?? new Set(); - connections.add(controller); - sseConnections.set(taskId, connections); + addSseConnection(taskId, controller); controller.enqueue( new TextEncoder().encode(`data: ${JSON.stringify({ kind: 'task', task })}\n\n`), @@ -147,17 +304,18 @@ export function createA2AHonoApp( try { await executor.execute( { task, message }, - createEventBus(taskId, taskStore, broadcastToTask), + createEventBus(taskId, taskStore, broadcastToTask, pushNotificationManager), ); const finalTask = await taskStore.get(taskId); if (finalTask && finalTask.status.state === 'working') { - await taskStore.updateStatus(taskId, { - state: 'completed', + const statusUpdate = { + state: 'completed' as const, timestamp: new Date().toISOString(), - }); + }; + await taskStore.updateStatus(taskId, statusUpdate); broadcastToTask(taskId, { kind: 'status', - status: { state: 'completed' }, + status: statusUpdate, final: true, }); } @@ -173,25 +331,22 @@ export function createA2AHonoApp( }); } finally { setTimeout(() => { - controller.close(); - connections.delete(controller); - if (connections.size === 0) sseConnections.delete(taskId); + removeSseConnection(taskId, controller); + try { + controller.close(); + } catch { + /* ignore close errors */ + } }, 500); } })(); }, cancel(controller) { - const connections = sseConnections.get(taskId); - if (connections) { - connections.delete(controller); - if (connections.size === 0) { - sseConnections.delete(taskId); - } - try { - controller.close(); - } catch { - /* ignore */ - } + removeSseConnection(taskId, controller); + try { + controller.close(); + } catch { + /* ignore */ } }, }); @@ -228,17 +383,14 @@ export function createA2AHonoApp( const stream = new ReadableStream({ start(controller) { - const connections = sseConnections.get(taskId) ?? new Set(); - connections.add(controller); - sseConnections.set(taskId, connections); + addSseConnection(taskId, controller); controller.enqueue( new TextEncoder().encode(`data: ${JSON.stringify({ kind: 'task', task })}\n\n`), ); c.req.raw.signal.addEventListener('abort', () => { - connections.delete(controller); - if (connections.size === 0) sseConnections.delete(taskId); + removeSseConnection(taskId, controller); try { controller.close(); } catch { @@ -277,9 +429,10 @@ export function createA2AHonoApp( (async () => { try { + const updatedTask = await taskStore.get(taskId); await executor.execute( - { task, message }, - createEventBus(taskId, taskStore, broadcastToTask), + { task: updatedTask ?? task, message }, + createEventBus(taskId, taskStore, broadcastToTask, pushNotificationManager), ); const finalTask = await taskStore.get(taskId); if (finalTask && finalTask.status.state === 'working') { @@ -318,14 +471,18 @@ export function createA2AHonoApp( const result = await taskStore.list({ contextId: validated.contextId, status: validated.status, + // Scope the query (and thus totalSize/pagination) to the caller's principal. + principal: auth?.principal, pageSize: validated.pageSize, pageToken: validated.pageToken, }); + // Defense-in-depth: the store already filtered by principal, so this is a no-op + // for principal-scoped stores but guards any store that ignores the option. const filtered = filterByPrincipal(result.tasks, auth); return { tasks: filtered, nextPageToken: result.nextPageToken, - totalSize: filtered.length, + totalSize: result.totalSize, }; }); @@ -345,7 +502,10 @@ export function createA2AHonoApp( } if (executor.cancelTask) { - await executor.cancelTask(task.id, createEventBus(task.id, taskStore, broadcastToTask)); + await executor.cancelTask( + task.id, + createEventBus(task.id, taskStore, broadcastToTask, pushNotificationManager), + ); } const updated = await taskStore.cancel(task.id); @@ -357,6 +517,95 @@ export function createA2AHonoApp( return updated; }); + // tasks/sendSubscribe (JSON-RPC style) + rpc.register('tasks/sendSubscribe', async (params, auth) => { + const validated = SendMessageRequestSchema.parse(params); + const message = validated.message; + const taskId = validated.taskId || generateTaskId(); + + const task: Task = { + id: taskId, + contextId: validated.contextId, + status: { state: 'submitted', timestamp: new Date().toISOString() }, + history: [message], + metadata: {}, + principal: auth?.principal, + }; + await taskStore.create(task); + await taskStore.updateStatus(taskId, { state: 'working', timestamp: new Date().toISOString() }); + + (async () => { + try { + await executor.execute( + { task, message }, + createEventBus(taskId, taskStore, broadcastToTask, pushNotificationManager), + ); + const finalTask = await taskStore.get(taskId); + if (finalTask && finalTask.status.state === 'working') { + await taskStore.updateStatus(taskId, { + state: 'completed', + timestamp: new Date().toISOString(), + }); + } + } catch { + await taskStore.updateStatus(taskId, { + state: 'failed', + timestamp: new Date().toISOString(), + }); + } + })(); + + return task; + }); + + // Push notification config management + rpc.register('tasks/pushNotification/set', async (params, _auth) => { + if (!pushNotificationsSupported) { + throw new PushNotificationNotSupportedError(); + } + const config = TaskPushNotificationConfigSchema.parse(params); + pushNotificationManager.register(config); + return { ok: true, id: config.id }; + }); + + rpc.register('tasks/pushNotification/get', async (params, _auth) => { + if (!pushNotificationsSupported) { + throw new PushNotificationNotSupportedError(); + } + const { taskId } = z.object({ taskId: z.string() }).parse(params); + const config = pushNotificationManager.getConfig(taskId); + if (!config) { + throw new TaskNotFoundError(taskId); + } + return config; + }); + + rpc.register('tasks/pushNotification/list', async (params, _auth) => { + if (!pushNotificationsSupported) { + throw new PushNotificationNotSupportedError(); + } + const { taskId } = z.object({ taskId: z.string().optional() }).parse(params); + return { configs: pushNotificationManager.listConfigs(taskId) }; + }); + + rpc.register('tasks/pushNotification/delete', async (params, _auth) => { + if (!pushNotificationsSupported) { + throw new PushNotificationNotSupportedError(); + } + const { taskId } = z.object({ taskId: z.string() }).parse(params); + pushNotificationManager.unregister(taskId); + return { ok: true }; + }); + + // Extended agent card + rpc.register('tasks/extendedAgentCard', async (params, _auth) => { + if (!options.extendedAgentCard) { + throw new ExtendedAgentCardNotConfiguredError(); + } + GetExtendedAgentCardRequestSchema.parse(params); + return options.extendedAgentCard; + }); + const shutdown = async (opts?: A2AHonoShutdownOptions) => { const timeoutMs = opts?.timeoutMs ?? 10_000; const deadline = Date.now() + timeoutMs; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 680f25a..4b6e924 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -5,3 +5,21 @@ export type { A2AHonoOptions, A2AHonoShutdownOptions } from './hono.js'; export { JsonRpcRouter, JsonRpcRequestSchema, JsonRpcResponseSchema } from './json-rpc.js'; export type { JsonRpcRequest, JsonRpcResponse, JsonRpcMethodHandler } from './json-rpc.js'; export type { AgentExecutor, ExecutionContext, ExecutionEventBus } from './executor.js'; +export { RateLimiter } from './rate-limiter.js'; +export type { RateLimiterOptions } from './rate-limiter.js'; +export { PushNotificationManager } from './push-notifications.js'; +export type { PushNotificationManagerOptions } from './push-notifications.js'; +export { createHealthStatus, HealthStatusSchema } from './health.js'; +export type { HealthStatus, HealthCheck, HealthCheckDependencies } from './health.js'; +export { RedisSseCoordinator } from './sse-redis.js'; +export type { RedisSseCoordinatorOptions } from './sse-redis.js'; +export { InMemoryTaskStore } from '@reaatech/a2a-reference-persistence'; +export type { TaskStore } from '@reaatech/a2a-reference-persistence'; +export { + createEventBus, + enforcePrincipal, + filterByPrincipal, + generateTaskId, + canTransition, +} from './shared.js'; +export type { BroadcastFn } from './shared.js'; diff --git a/packages/server/src/push-notifications.test.ts b/packages/server/src/push-notifications.test.ts new file mode 100644 index 0000000..29f1d48 --- /dev/null +++ b/packages/server/src/push-notifications.test.ts @@ -0,0 +1,427 @@ +import type { Task, TaskPushNotificationConfig } from '@reaatech/a2a-reference-core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PushNotificationManager } from './push-notifications.js'; + +function makeTask(overrides: Partial = {}): Task { + return { + id: 'task-1', + status: { state: 'submitted', timestamp: new Date().toISOString() }, + ...overrides, + }; +} + +function makeConfig( + overrides: Partial = {}, +): TaskPushNotificationConfig { + return { + url: 'https://example.com/webhook', + taskId: 'task-1', + ...overrides, + }; +} + +function makeStatusEvent(status: Task['status']['state'] = 'completed', final = true) { + return { + kind: 'status' as const, + taskId: 'task-1', + status: { state: status, timestamp: new Date().toISOString() }, + final, + } as const; +} + +function makeArtifactEvent() { + return { + kind: 'artifact' as const, + taskId: 'task-1', + artifact: { parts: [{ kind: 'text' as const, text: 'result' }] }, + append: false, + lastChunk: true, + }; +} + +describe('PushNotificationManager', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.fn().mockResolvedValue({ ok: true, text: vi.fn().mockResolvedValue('') }); + vi.stubGlobal('fetch', fetchSpy); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + describe('register / unregister', () => { + it('registers a push notification config', () => { + const mgr = new PushNotificationManager(); + const config = makeConfig(); + + mgr.register(config); + + expect(mgr.getConfig('task-1')).toBe(config); + }); + + it('unregisters a push notification config', () => { + const mgr = new PushNotificationManager(); + mgr.register(makeConfig()); + mgr.unregister('task-1'); + + expect(mgr.getConfig('task-1')).toBeUndefined(); + }); + + it('listConfigs returns all configs when no taskId given', () => { + const mgr = new PushNotificationManager(); + mgr.register(makeConfig({ taskId: 't1', url: 'https://a.com' })); + mgr.register(makeConfig({ taskId: 't2', url: 'https://b.com' })); + + const list = mgr.listConfigs(); + expect(list).toHaveLength(2); + }); + + it('listConfigs returns config for a specific taskId', () => { + const mgr = new PushNotificationManager(); + mgr.register(makeConfig({ taskId: 't1', url: 'https://a.com' })); + mgr.register(makeConfig({ taskId: 't2', url: 'https://b.com' })); + + const list = mgr.listConfigs('t1'); + expect(list).toHaveLength(1); + expect(list[0].url).toBe('https://a.com'); + }); + + it('listConfigs returns empty array for unknown taskId', () => { + const mgr = new PushNotificationManager(); + const list = mgr.listConfigs('unknown'); + expect(list).toEqual([]); + }); + + it('unregisterAll clears everything when no taskId given', () => { + const mgr = new PushNotificationManager(); + mgr.register(makeConfig({ taskId: 't1' })); + mgr.register(makeConfig({ taskId: 't2' })); + mgr.unregisterAll(); + + expect(mgr.listConfigs()).toHaveLength(0); + }); + + it('unregisterAll removes only the specified taskId', () => { + const mgr = new PushNotificationManager(); + mgr.register(makeConfig({ taskId: 't1' })); + mgr.register(makeConfig({ taskId: 't2' })); + mgr.unregisterAll('t1'); + + expect(mgr.listConfigs()).toHaveLength(1); + expect(mgr.listConfigs()[0].taskId).toBe('t2'); + }); + }); + + describe('notifyStatusUpdate', () => { + it('sends a POST to the webhook URL', async () => { + const mgr = new PushNotificationManager(); + mgr.register(makeConfig()); + + const result = await mgr.notifyStatusUpdate(makeTask(), makeStatusEvent()); + + expect(result).toBe(true); + expect(fetchSpy).toHaveBeenCalledOnce(); + const [url, opts] = fetchSpy.mock.calls[0]; + expect(url).toBe('https://example.com/webhook'); + expect(opts.method).toBe('POST'); + }); + + it('returns false when no config is registered for the task', async () => { + const mgr = new PushNotificationManager(); + const result = await mgr.notifyStatusUpdate(makeTask(), makeStatusEvent()); + expect(result).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('sends status payload as JSON body', async () => { + const mgr = new PushNotificationManager(); + mgr.register(makeConfig()); + + const event = makeStatusEvent('working', false); + await mgr.notifyStatusUpdate(makeTask({ id: 'task-1' }), event); + + const [, opts] = fetchSpy.mock.calls[0]; + const body = JSON.parse(opts.body as string); + expect(body).toEqual({ + kind: 'status', + taskId: 'task-1', + status: event.status, + final: false, + }); + expect(opts.headers['Content-Type']).toBe('application/json'); + }); + + it('deduplicates identical status notifications', async () => { + const mgr = new PushNotificationManager(); + mgr.register(makeConfig()); + + const task = makeTask({ id: 'task-1' }); + const event = makeStatusEvent('completed', true); + + await mgr.notifyStatusUpdate(task, event); + await mgr.notifyStatusUpdate(task, event); + + // Second call is deduplicated — only one fetch call is made + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('notifyArtifactUpdate', () => { + it('sends a POST to the webhook URL', async () => { + const mgr = new PushNotificationManager(); + mgr.register(makeConfig()); + + const result = await mgr.notifyArtifactUpdate(makeTask(), makeArtifactEvent()); + + expect(result).toBe(true); + expect(fetchSpy).toHaveBeenCalledOnce(); + const [url] = fetchSpy.mock.calls[0]; + expect(url).toBe('https://example.com/webhook'); + }); + + it('returns false when no config is registered', async () => { + const mgr = new PushNotificationManager(); + const result = await mgr.notifyArtifactUpdate(makeTask(), makeArtifactEvent()); + expect(result).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('sends artifact payload as JSON body', async () => { + const mgr = new PushNotificationManager(); + mgr.register(makeConfig()); + + const event = makeArtifactEvent(); + await mgr.notifyArtifactUpdate(makeTask({ id: 'task-1' }), event); + + const [, opts] = fetchSpy.mock.calls[0]; + const body = JSON.parse(opts.body as string); + expect(body).toEqual({ + kind: 'artifact', + taskId: 'task-1', + artifact: event.artifact, + append: false, + lastChunk: true, + }); + }); + }); + + describe('retry on failure', () => { + it('retries on non-ok response', async () => { + fetchSpy + .mockResolvedValueOnce({ ok: false, status: 500, text: vi.fn().mockResolvedValue('err') }) + .mockResolvedValueOnce({ ok: true, text: vi.fn().mockResolvedValue('') }); + + const mgr = new PushNotificationManager({ maxRetries: 3, retryDelayMs: 5 }); + mgr.register(makeConfig()); + + const result = await mgr.notifyStatusUpdate(makeTask(), makeStatusEvent()); + + expect(result).toBe(true); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('returns false after exhausting retries', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + status: 500, + text: vi.fn().mockResolvedValue('err'), + }); + + const mgr = new PushNotificationManager({ maxRetries: 2, retryDelayMs: 5 }); + mgr.register(makeConfig()); + + const result = await mgr.notifyStatusUpdate(makeTask(), makeStatusEvent()); + + expect(result).toBe(false); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('retries on fetch error (network failure)', async () => { + fetchSpy + .mockRejectedValueOnce(new Error('network error')) + .mockResolvedValueOnce({ ok: true, text: vi.fn().mockResolvedValue('') }); + + const mgr = new PushNotificationManager({ maxRetries: 3, retryDelayMs: 5 }); + mgr.register(makeConfig()); + + const result = await mgr.notifyStatusUpdate(makeTask(), makeStatusEvent()); + + expect(result).toBe(true); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('uses exponential backoff delay', async () => { + fetchSpy.mockResolvedValue({ + ok: false, + status: 500, + text: vi.fn().mockResolvedValue('err'), + }); + + const mgr = new PushNotificationManager({ maxRetries: 4, retryDelayMs: 10 }); + mgr.register(makeConfig()); + + const start = Date.now(); + await mgr.notifyStatusUpdate(makeTask(), makeStatusEvent()); + const elapsed = Date.now() - start; + + // Expected delays: 10 + 20 + 40 = 70ms minimum + expect(elapsed).toBeGreaterThanOrEqual(60); + }); + }); + + describe('dedup entries bounded', () => { + it('evicts old entries when maxDedupEntries is exceeded', async () => { + const mgr = new PushNotificationManager({ + maxDedupEntries: 3, + maxRetries: 1, + retryDelayMs: 5, + }); + mgr.register(makeConfig()); + + // Send 3 different statuses to fill the dedup map + for (let i = 0; i < 3; i++) { + await mgr.notifyStatusUpdate(makeTask({ id: 'task-1' }), { + kind: 'status', + status: { state: 'working', timestamp: `2025-01-01T00:00:0${i}Z` }, + }); + } + + // The dedup map should now be at capacity (3) + // The 4th call triggers eviction of 50% (floor(1.5) = 1 entry) + await mgr.notifyStatusUpdate(makeTask({ id: 'task-1' }), { + kind: 'status', + status: { state: 'working', timestamp: '2025-01-01T00:00:03Z' }, + }); + + // Each call should result in a fetch (different dedup keys due to Math.random) + expect(fetchSpy).toHaveBeenCalledTimes(4); + }); + + it('does not evict below maxDedupEntries threshold', async () => { + // Use a low max to see eviction behavior + const mgr = new PushNotificationManager({ + maxDedupEntries: 100, + maxRetries: 1, + retryDelayMs: 5, + }); + mgr.register(makeConfig()); + + for (let i = 0; i < 50; i++) { + await mgr.notifyStatusUpdate(makeTask({ id: 'task-1' }), { + kind: 'status', + status: { state: 'working', timestamp: `2025-01-01T00:00:0${i}Z` }, + }); + } + + expect(fetchSpy).toHaveBeenCalledTimes(50); + }); + }); + + describe('missing taskId throws', () => { + it('throws when config has no taskId', () => { + const mgr = new PushNotificationManager(); + const config = makeConfig({ taskId: '' }); + + expect(() => mgr.register(config)).toThrow('taskId is required'); + }); + }); + + describe('auth header generation', () => { + it('generates Bearer token header when token is present', async () => { + const mgr = new PushNotificationManager({ maxRetries: 1, retryDelayMs: 5 }); + mgr.register(makeConfig({ token: 'secret-token' })); + + await mgr.notifyStatusUpdate(makeTask(), makeStatusEvent()); + + const [, opts] = fetchSpy.mock.calls[0]; + expect(opts.headers.Authorization).toBe('Bearer secret-token'); + }); + + it('generates apiKey header when authentication scheme is apiKey', async () => { + const mgr = new PushNotificationManager({ maxRetries: 1, retryDelayMs: 5 }); + mgr.register( + makeConfig({ + authentication: { scheme: 'apiKey', credentials: 'my-key' }, + }), + ); + + await mgr.notifyStatusUpdate(makeTask(), makeStatusEvent()); + + const [, opts] = fetchSpy.mock.calls[0]; + expect(opts.headers['x-api-key']).toBe('my-key'); + }); + + it('uses custom headerName for apiKey auth when provided', async () => { + const mgr = new PushNotificationManager({ maxRetries: 1, retryDelayMs: 5 }); + mgr.register( + makeConfig({ + authentication: { scheme: 'apiKey', credentials: 'my-key', headerName: 'X-Custom-Auth' }, + }), + ); + + await mgr.notifyStatusUpdate(makeTask(), makeStatusEvent()); + + const [, opts] = fetchSpy.mock.calls[0]; + expect(opts.headers['X-Custom-Auth']).toBe('my-key'); + }); + + it('prefers token over authentication object', async () => { + const mgr = new PushNotificationManager({ maxRetries: 1, retryDelayMs: 5 }); + mgr.register( + makeConfig({ + token: 'token-wins', + authentication: { scheme: 'apiKey', credentials: 'key-loses' }, + }), + ); + + await mgr.notifyStatusUpdate(makeTask(), makeStatusEvent()); + + const [, opts] = fetchSpy.mock.calls[0]; + expect(opts.headers.Authorization).toBe('Bearer token-wins'); + expect(opts.headers['x-api-key']).toBeUndefined(); + }); + + it('sends no auth header when neither token nor authentication is configured', async () => { + const mgr = new PushNotificationManager({ maxRetries: 1, retryDelayMs: 5 }); + mgr.register(makeConfig({ token: undefined })); + + await mgr.notifyStatusUpdate(makeTask(), makeStatusEvent()); + + const [, opts] = fetchSpy.mock.calls[0]; + expect(opts.headers.Authorization).toBeUndefined(); + expect(opts.headers['x-api-key']).toBeUndefined(); + }); + }); + + describe('User-Agent header', () => { + it('sends a2a User-Agent header', async () => { + const mgr = new PushNotificationManager({ maxRetries: 1, retryDelayMs: 5 }); + mgr.register(makeConfig()); + + await mgr.notifyStatusUpdate(makeTask(), makeStatusEvent()); + + const [, opts] = fetchSpy.mock.calls[0]; + expect(opts.headers['User-Agent']).toBe('a2a-reference-push-notification/0.1.0'); + }); + }); + + describe('config defaults', () => { + it('uses default logger when none provided', () => { + const mgr = new PushNotificationManager(); + expect(mgr).toBeDefined(); + }); + + it('uses default maxRetries when not provided', async () => { + fetchSpy.mockRejectedValue(new Error('fail')); + const mgr = new PushNotificationManager({ retryDelayMs: 5 }); + mgr.register(makeConfig()); + + await mgr.notifyStatusUpdate(makeTask(), makeStatusEvent()); + + // Default maxRetries = 3, fetch should be called 3 times (attempt 0, 1, 2) + expect(fetchSpy).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/packages/server/src/push-notifications.ts b/packages/server/src/push-notifications.ts new file mode 100644 index 0000000..218fd05 --- /dev/null +++ b/packages/server/src/push-notifications.ts @@ -0,0 +1,180 @@ +import type { + Task, + TaskArtifactUpdateEvent, + TaskPushNotificationConfig, + TaskStatusUpdateEvent, +} from '@reaatech/a2a-reference-core'; +import { type Logger, defaultLogger } from '@reaatech/a2a-reference-observability'; + +const DEFAULT_USER_AGENT = 'a2a-reference-push-notification/0.1.0'; + +export interface PushNotificationManagerOptions { + logger?: Logger; + maxRetries?: number; + retryDelayMs?: number; + maxDedupEntries?: number; + userAgent?: string; +} + +const DEFAULT_MAX_DEDUP_ENTRIES = 10_000; + +export class PushNotificationManager { + private configs = new Map(); + private sentNotifications = new Map(); + private logger: Logger; + private maxRetries: number; + private retryDelayMs: number; + private maxDedupEntries: number; + private userAgent: string; + + constructor(options?: PushNotificationManagerOptions) { + this.logger = options?.logger ?? defaultLogger; + this.maxRetries = options?.maxRetries ?? 3; + this.retryDelayMs = options?.retryDelayMs ?? 1000; + this.maxDedupEntries = options?.maxDedupEntries ?? DEFAULT_MAX_DEDUP_ENTRIES; + this.userAgent = options?.userAgent ?? DEFAULT_USER_AGENT; + } + + register(config: TaskPushNotificationConfig): void { + if (!config.taskId) { + // Message wording maps to a JSON-RPC -32602 (invalid params) in JsonRpcRouter. + throw new Error('invalid push notification config: taskId is required'); + } + this.configs.set(config.taskId, config); + this.logger.info({ taskId: config.taskId }, 'push notification config registered'); + } + + unregister(taskId: string): void { + this.configs.delete(taskId); + } + + getConfig(taskId: string): TaskPushNotificationConfig | undefined { + return this.configs.get(taskId); + } + + listConfigs(taskId?: string): TaskPushNotificationConfig[] { + if (taskId) { + const config = this.configs.get(taskId); + return config ? [config] : []; + } + return Array.from(this.configs.values()); + } + + unregisterAll(taskId?: string): void { + if (taskId) { + this.configs.delete(taskId); + } else { + this.configs.clear(); + } + } + + async notifyStatusUpdate(task: Task, event: TaskStatusUpdateEvent): Promise { + const config = this.configs.get(task.id); + if (!config) return false; + + const dedupKey = `${task.id}:status:${event.status.state}:${event.status.timestamp ?? Date.now()}`; + if (this.sentNotifications.has(dedupKey)) return true; + this.evictDedupIfNeeded(); + this.sentNotifications.set(dedupKey, Date.now()); + + return this.deliver(config, { + kind: 'status', + taskId: task.id, + status: event.status, + final: event.final, + }); + } + + async notifyArtifactUpdate(task: Task, event: TaskArtifactUpdateEvent): Promise { + const config = this.configs.get(task.id); + if (!config) return false; + + return this.deliver(config, { + kind: 'artifact', + taskId: task.id, + artifact: event.artifact, + append: event.append, + lastChunk: event.lastChunk, + }); + } + + private evictDedupIfNeeded(): void { + if (this.sentNotifications.size < this.maxDedupEntries) return; + const sorted = Array.from(this.sentNotifications.entries()).sort(([, a], [, b]) => a - b); + const toDelete = sorted.slice(0, Math.floor(this.maxDedupEntries * 0.5)); + for (const [key] of toDelete) { + this.sentNotifications.delete(key); + } + } + + private getAuthHeader(config: TaskPushNotificationConfig): [string, string] | null { + if (config.token) { + return ['Authorization', `Bearer ${config.token}`]; + } + + if ( + config.authentication && + typeof config.authentication === 'object' && + !Array.isArray(config.authentication) + ) { + const auth = config.authentication as Record; + if (auth.scheme === 'apiKey' && typeof auth.credentials === 'string') { + const headerName = typeof auth.headerName === 'string' ? auth.headerName : 'x-api-key'; + return [headerName, auth.credentials]; + } + } + + return null; + } + + private async deliver(config: TaskPushNotificationConfig, payload: unknown): Promise { + const headers: Record = { + 'Content-Type': 'application/json', + 'User-Agent': this.userAgent, + }; + + const authHeader = this.getAuthHeader(config); + if (authHeader) { + headers[authHeader[0]] = authHeader[1]; + } + + for (let attempt = 0; attempt < this.maxRetries; attempt++) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10_000); + + try { + const response = await fetch(config.url, { + method: 'POST', + headers, + body: JSON.stringify(payload), + signal: controller.signal, + }); + + if (response.ok) { + return true; + } + + const body = await response.text().catch(() => ''); + this.logger.warn( + { url: config.url, status: response.status, body, attempt }, + 'push notification delivery failed', + ); + } finally { + clearTimeout(timeoutId); + } + } catch (err) { + this.logger.error( + { url: config.url, attempt, error: err instanceof Error ? err.message : String(err) }, + 'push notification delivery error', + ); + } + + if (attempt < this.maxRetries - 1) { + await new Promise((resolve) => setTimeout(resolve, this.retryDelayMs * 2 ** attempt)); + } + } + + return false; + } +} diff --git a/packages/server/src/rate-limiter.test.ts b/packages/server/src/rate-limiter.test.ts new file mode 100644 index 0000000..5558259 --- /dev/null +++ b/packages/server/src/rate-limiter.test.ts @@ -0,0 +1,209 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { RateLimiter } from './rate-limiter.js'; + +describe('RateLimiter', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('allows a request under the max limit', () => { + const limiter = new RateLimiter({ windowMs: 60_000, maxRequests: 10 }); + + const result = limiter.check({ ip: '127.0.0.1', headers: {} }); + + expect(result.allowed).toBe(true); + expect(result.remaining).toBe(9); + expect(typeof result.resetAt).toBe('number'); + }); + + it('returns correct remaining count for multiple requests', () => { + const limiter = new RateLimiter({ windowMs: 60_000, maxRequests: 5 }); + const req = { ip: '127.0.0.1', headers: {} }; + + expect(limiter.check(req).remaining).toBe(4); + expect(limiter.check(req).remaining).toBe(3); + expect(limiter.check(req).remaining).toBe(2); + expect(limiter.check(req).remaining).toBe(1); + expect(limiter.check(req).remaining).toBe(0); + }); + + it('blocks requests over the max limit', () => { + const limiter = new RateLimiter({ windowMs: 60_000, maxRequests: 3 }); + const req = { ip: '127.0.0.1', headers: {} }; + + limiter.check(req); + limiter.check(req); + limiter.check(req); + + const result = limiter.check(req); + expect(result.allowed).toBe(false); + expect(result.remaining).toBe(0); + }); + + it('resets the window after windowMs elapses', () => { + const limiter = new RateLimiter({ windowMs: 10_000, maxRequests: 2 }); + const req = { ip: '127.0.0.1', headers: {} }; + + limiter.check(req); + limiter.check(req); + // Exhausted + expect(limiter.check(req).allowed).toBe(false); + + // Advance past the window + vi.advanceTimersByTime(10_000); + + // Should reset + const result = limiter.check(req); + expect(result.allowed).toBe(true); + expect(result.remaining).toBe(1); + }); + + it('resets the window for each key independently', () => { + const limiter = new RateLimiter({ windowMs: 10_000, maxRequests: 2 }); + const reqA = { ip: '10.0.0.1', headers: {} }; + const reqB = { ip: '10.0.0.2', headers: {} }; + + limiter.check(reqA); + limiter.check(reqA); + expect(limiter.check(reqA).allowed).toBe(false); + + // Different IP should still be allowed + expect(limiter.check(reqB).allowed).toBe(true); + + // Advance time + vi.advanceTimersByTime(10_000); + + // Both should be reset + expect(limiter.check(reqA).allowed).toBe(true); + expect(limiter.check(reqB).remaining).toBe(1); + }); + + it('uses custom key function', () => { + const limiter = new RateLimiter({ + windowMs: 60_000, + maxRequests: 1, + keyFn: (req) => { + const header = req.headers['x-api-key']; + const value = Array.isArray(header) ? header[0] : header; + return value ?? 'unknown'; + }, + }); + + const req = { ip: '127.0.0.1', headers: { 'x-api-key': 'key-1' } }; + + expect(limiter.check(req).allowed).toBe(true); + // Same key → blocked + expect(limiter.check(req).allowed).toBe(false); + + // Different key → allowed + const req2 = { ip: '127.0.0.1', headers: { 'x-api-key': 'key-2' } }; + expect(limiter.check(req2).allowed).toBe(true); + }); + + it('falls back to "unknown" when no ip and no custom keyFn', () => { + const limiter = new RateLimiter({ windowMs: 60_000, maxRequests: 2 }); + const req = { headers: {} }; + + expect(limiter.check(req).allowed).toBe(true); + expect(limiter.check(req).allowed).toBe(true); + expect(limiter.check(req).allowed).toBe(false); + }); + + it('uses array header values correctly in default keyFn', () => { + const limiter = new RateLimiter({ windowMs: 60_000, maxRequests: 2 }); + const req = { headers: { 'x-forwarded-for': ['10.0.0.1'] } } as any; + + // Default keyFn uses req.ip, not x-forwarded-for, so ip is undefined + const r1 = limiter.check(req); + // "unknown" key + expect(r1.allowed).toBe(true); + }); + + it('logs a warning when rate limit is exceeded', () => { + const warnFn = vi.fn(); + const limiter = new RateLimiter({ + windowMs: 60_000, + maxRequests: 1, + logger: { + warn: warnFn, + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + fatal: vi.fn(), + trace: vi.fn(), + silent: vi.fn(), + } as any, + }); + const req = { ip: '10.0.0.1', headers: {} }; + + limiter.check(req); + expect(warnFn).not.toHaveBeenCalled(); + + limiter.check(req); + expect(warnFn).toHaveBeenCalledTimes(1); + expect(warnFn).toHaveBeenCalledWith( + expect.objectContaining({ key: '10.0.0.1', count: 2, limit: 1 }), + 'rate limit exceeded', + ); + }); + + it('cleanup removes expired entries', () => { + const limiter = new RateLimiter({ windowMs: 10_000, maxRequests: 10 }); + + limiter.check({ ip: '10.0.0.1', headers: {} }); + limiter.check({ ip: '10.0.0.2', headers: {} }); + + // Advance time past the window + vi.advanceTimersByTime(10_000); + + // Trigger cleanup (removes only expired entries) + limiter.cleanup(); + + // After cleanup, fresh window + const result = limiter.check({ ip: '10.0.0.1', headers: {} }); + expect(result.allowed).toBe(true); + expect(result.remaining).toBe(9); + }); + + it('close clears the interval and windows', () => { + const limiter = new RateLimiter({ windowMs: 60_000, maxRequests: 10 }); + + limiter.check({ ip: '10.0.0.1', headers: {} }); + limiter.close(); + + // After close, windows should be empty + const result = limiter.check({ ip: '10.0.0.1', headers: {} }); + // It'll start fresh since windows were cleared + expect(result.allowed).toBe(true); + expect(result.remaining).toBe(9); + }); + + it('reset clears all windows', () => { + const limiter = new RateLimiter({ windowMs: 60_000, maxRequests: 2 }); + const req = { ip: '10.0.0.1', headers: {} }; + + limiter.check(req); + limiter.check(req); + expect(limiter.check(req).allowed).toBe(false); + + limiter.reset(); + + expect(limiter.check(req).allowed).toBe(true); + expect(limiter.check(req).remaining).toBe(0); + }); + + it('applies default options when none are provided', () => { + const limiter = new RateLimiter(); + const req = { ip: '127.0.0.1', headers: {} }; + + for (let i = 0; i < 100; i++) { + limiter.check(req); + } + // 100th should be at exactly the limit → allowed + expect(limiter.check(req).allowed).toBe(false); + }); +}); diff --git a/packages/server/src/rate-limiter.ts b/packages/server/src/rate-limiter.ts new file mode 100644 index 0000000..d2adfde --- /dev/null +++ b/packages/server/src/rate-limiter.ts @@ -0,0 +1,79 @@ +import { type Logger, defaultLogger } from '@reaatech/a2a-reference-observability'; + +export interface RateLimiterOptions { + windowMs?: number; + maxRequests?: number; + keyFn?: (req: { ip?: string; headers: Record }) => string; + logger?: Logger; +} + +interface WindowEntry { + count: number; + resetAt: number; +} + +/** + * In-memory sliding-window rate limiter. + * + * For distributed deployments, a shared store backend (e.g. Redis) should be + * provided via a custom rate limiting implementation. + */ +export class RateLimiter { + private windows = new Map(); + private windowMs: number; + private maxRequests: number; + private keyFn: NonNullable; + private logger: Logger; + private cleanupInterval: ReturnType; + + constructor(options?: RateLimiterOptions) { + this.windowMs = options?.windowMs ?? 60_000; + this.maxRequests = options?.maxRequests ?? 100; + this.keyFn = options?.keyFn ?? ((req) => req.ip ?? 'unknown'); + this.logger = options?.logger ?? defaultLogger; + this.cleanupInterval = setInterval(() => this.cleanup(), this.windowMs * 2); + this.cleanupInterval.unref(); + } + + check(req: { ip?: string; headers: Record }): { + allowed: boolean; + remaining: number; + resetAt: number; + } { + const key = this.keyFn(req); + const now = Date.now(); + let entry = this.windows.get(key); + + if (!entry || now >= entry.resetAt) { + entry = { count: 1, resetAt: now + this.windowMs }; + this.windows.set(key, entry); + return { allowed: true, remaining: this.maxRequests - 1, resetAt: entry.resetAt }; + } + + entry.count++; + if (entry.count > this.maxRequests) { + this.logger.warn({ key, count: entry.count, limit: this.maxRequests }, 'rate limit exceeded'); + return { allowed: false, remaining: 0, resetAt: entry.resetAt }; + } + + return { allowed: true, remaining: this.maxRequests - entry.count, resetAt: entry.resetAt }; + } + + cleanup(): void { + const now = Date.now(); + for (const [key, entry] of this.windows) { + if (now >= entry.resetAt) { + this.windows.delete(key); + } + } + } + + close(): void { + clearInterval(this.cleanupInterval); + this.windows.clear(); + } + + reset(): void { + this.windows.clear(); + } +} diff --git a/packages/server/src/shared.ts b/packages/server/src/shared.ts index 5b46d96..8ef8e19 100644 --- a/packages/server/src/shared.ts +++ b/packages/server/src/shared.ts @@ -1,12 +1,23 @@ import type { AuthResult } from '@reaatech/a2a-reference-auth'; +import { defaultLogger } from '@reaatech/a2a-reference-observability'; import type { TaskStore } from '@reaatech/a2a-reference-persistence'; import type { ExecutionEventBus } from './executor.js'; +import type { PushNotificationManager } from './push-notifications.js'; export const VALID_STATE_TRANSITIONS: Record = { - submitted: ['working', 'input-required', 'completed', 'failed', 'canceled', 'rejected'], - working: ['input-required', 'completed', 'failed', 'canceled'], - 'input-required': ['working', 'completed', 'failed', 'canceled'], + submitted: [ + 'working', + 'input-required', + 'completed', + 'failed', + 'canceled', + 'rejected', + 'auth-required', + ], + working: ['input-required', 'completed', 'failed', 'canceled', 'auth-required'], + 'input-required': ['working', 'completed', 'failed', 'canceled', 'auth-required'], + 'auth-required': ['working', 'completed', 'failed', 'canceled'], completed: [], failed: [], canceled: [], @@ -27,6 +38,7 @@ export function createEventBus( taskId: string, taskStore: TaskStore, broadcast: BroadcastFn, + pushNotificationManager?: PushNotificationManager, ): ExecutionEventBus { return { emitStatusUpdate: async (event) => { @@ -40,10 +52,26 @@ export function createEventBus( } await taskStore.updateStatus(taskId, event.status); broadcast(taskId, { kind: 'status', status: event.status, final: event.final }); + if (pushNotificationManager) { + // Fire-and-forget: webhook delivery (with retries) must not block execution. + void pushNotificationManager + .notifyStatusUpdate(task, event) + .catch((err) => defaultLogger.error({ err, taskId }, 'push status notification failed')); + } }, emitArtifactUpdate: async (event) => { await taskStore.addArtifact(taskId, event.artifact); broadcast(taskId, { kind: 'artifact', artifact: event.artifact }); + if (pushNotificationManager) { + const task = await taskStore.get(taskId); + if (task) { + void pushNotificationManager + .notifyArtifactUpdate(task, event) + .catch((err) => + defaultLogger.error({ err, taskId }, 'push artifact notification failed'), + ); + } + } }, }; } diff --git a/packages/server/src/sse-redis.test.ts b/packages/server/src/sse-redis.test.ts new file mode 100644 index 0000000..2f11445 --- /dev/null +++ b/packages/server/src/sse-redis.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it, vi } from 'vitest'; +import { RedisSseCoordinator } from './sse-redis.js'; + +function createMockRedis() { + const handlers = new Map void>(); + return { + duplicate: vi.fn().mockReturnThis(), + psubscribe: vi.fn().mockResolvedValue(undefined), + publish: vi.fn().mockResolvedValue(1), + punsubscribe: vi.fn().mockResolvedValue(undefined), + quit: vi.fn().mockResolvedValue(undefined), + on: vi.fn((event: string, handler: (...args: unknown[]) => void) => { + handlers.set(event, handler); + }), + emit: vi.fn((event: string, ...args: unknown[]) => { + const h = handlers.get(event); + if (h) h(...args); + }), + }; +} + +type MockRedis = ReturnType; + +describe('RedisSseCoordinator', () => { + describe('connect', () => { + it('creates a subscriber and subscribes to the psubject', async () => { + const redis = createMockRedis() as unknown as MockRedis; + const coordinator = new RedisSseCoordinator({ + redis: redis as never, + channelPrefix: 'a2a:sse', + }); + + await coordinator.connect(); + + expect(redis.duplicate).toHaveBeenCalledOnce(); + expect(redis.psubscribe).toHaveBeenCalledWith('a2a:sse:*'); + }); + + it('does nothing if already connected', async () => { + const redis = createMockRedis() as unknown as MockRedis; + const coordinator = new RedisSseCoordinator({ + redis: redis as never, + }); + + await coordinator.connect(); + await coordinator.connect(); + + expect(redis.duplicate).toHaveBeenCalledTimes(1); + expect(redis.psubscribe).toHaveBeenCalledTimes(1); + }); + + it('deduplicates concurrent connect calls', async () => { + const redis = createMockRedis() as unknown as MockRedis; + const coordinator = new RedisSseCoordinator({ + redis: redis as never, + }); + + await Promise.all([coordinator.connect(), coordinator.connect()]); + + expect(redis.duplicate).toHaveBeenCalledTimes(1); + expect(redis.psubscribe).toHaveBeenCalledTimes(1); + }); + + it('connects on init when connectOnInit is true', async () => { + const redis = createMockRedis() as unknown as MockRedis; + void new RedisSseCoordinator({ + redis: redis as never, + connectOnInit: true, + }); + + expect(redis.duplicate).toHaveBeenCalledOnce(); + await vi.waitFor(() => { + expect(redis.psubscribe).toHaveBeenCalledWith('a2a:sse:*'); + }); + }); + }); + + describe('subscribe / unsubscribe', () => { + it('calls the handler when a matching message is received', async () => { + const redis = createMockRedis() as unknown as MockRedis; + const coordinator = new RedisSseCoordinator({ + redis: redis as never, + channelPrefix: 'a2a:sse', + }); + await coordinator.connect(); + + const handler = vi.fn(); + coordinator.subscribe('task-1', handler); + + redis.emit('pmessage', 'a2a:sse:*', 'a2a:sse:task-1', JSON.stringify({ hello: 'world' })); + + expect(handler).toHaveBeenCalledWith({ hello: 'world' }); + }); + + it('does not call the handler for a different task', async () => { + const redis = createMockRedis() as unknown as MockRedis; + const coordinator = new RedisSseCoordinator({ + redis: redis as never, + }); + await coordinator.connect(); + + const handler = vi.fn(); + coordinator.subscribe('task-1', handler); + + redis.emit('pmessage', 'a2a:sse:*', 'a2a:sse:task-2', JSON.stringify({})); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('does not call the handler after unsubscribe', async () => { + const redis = createMockRedis() as unknown as MockRedis; + const coordinator = new RedisSseCoordinator({ + redis: redis as never, + }); + await coordinator.connect(); + + const handler = vi.fn(); + coordinator.subscribe('task-1', handler); + coordinator.unsubscribe('task-1'); + + redis.emit('pmessage', 'a2a:sse:*', 'a2a:sse:task-1', JSON.stringify({})); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('ignores malformed JSON messages', async () => { + const redis = createMockRedis() as unknown as MockRedis; + const coordinator = new RedisSseCoordinator({ + redis: redis as never, + }); + await coordinator.connect(); + + const handler = vi.fn(); + coordinator.subscribe('task-1', handler); + + redis.emit('pmessage', 'a2a:sse:*', 'a2a:sse:task-1', 'not-json'); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('ignores messages on channels without the prefix', async () => { + const redis = createMockRedis() as unknown as MockRedis; + const coordinator = new RedisSseCoordinator({ + redis: redis as never, + }); + await coordinator.connect(); + + const handler = vi.fn(); + coordinator.subscribe('task-1', handler); + + redis.emit('pmessage', 'a2a:sse:*', 'other:task-1', JSON.stringify({})); + + expect(handler).not.toHaveBeenCalled(); + }); + }); + + describe('publish', () => { + it('publishes JSON-stringified data to the correct channel', async () => { + const redis = createMockRedis() as unknown as MockRedis; + const coordinator = new RedisSseCoordinator({ + redis: redis as never, + channelPrefix: 'a2a:sse', + }); + + await coordinator.publish('task-1', { status: 'ok' }); + + expect(redis.publish).toHaveBeenCalledWith( + 'a2a:sse:task-1', + JSON.stringify({ status: 'ok' }), + ); + }); + + it('uses custom channel prefix', async () => { + const redis = createMockRedis() as unknown as MockRedis; + const coordinator = new RedisSseCoordinator({ + redis: redis as never, + channelPrefix: 'custom', + }); + + await coordinator.publish('task-99', { data: 1 }); + + expect(redis.publish).toHaveBeenCalledWith('custom:task-99', JSON.stringify({ data: 1 })); + }); + }); + + describe('close', () => { + it('unsubscribes and quits the subscriber', async () => { + const redis = createMockRedis() as unknown as MockRedis; + const coordinator = new RedisSseCoordinator({ + redis: redis as never, + }); + await coordinator.connect(); + + await coordinator.close(); + + expect(redis.punsubscribe).toHaveBeenCalledOnce(); + expect(redis.quit).toHaveBeenCalledOnce(); + }); + + it('clears all handlers', async () => { + const redis = createMockRedis() as unknown as MockRedis; + const coordinator = new RedisSseCoordinator({ + redis: redis as never, + }); + await coordinator.connect(); + + coordinator.subscribe('task-1', vi.fn()); + coordinator.subscribe('task-2', vi.fn()); + + await coordinator.close(); + + redis.emit('pmessage', 'a2a:sse:*', 'a2a:sse:task-1', JSON.stringify({})); + + // No handler should be called since they were cleared + }); + + it('is a no-op when not connected', async () => { + const redis = createMockRedis() as unknown as MockRedis; + const coordinator = new RedisSseCoordinator({ + redis: redis as never, + }); + + await coordinator.close(); + + expect(redis.punsubscribe).not.toHaveBeenCalled(); + expect(redis.quit).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/server/src/sse-redis.ts b/packages/server/src/sse-redis.ts new file mode 100644 index 0000000..f244003 --- /dev/null +++ b/packages/server/src/sse-redis.ts @@ -0,0 +1,95 @@ +import { defaultLogger } from '@reaatech/a2a-reference-observability'; +import type { Redis } from 'ioredis'; + +export interface RedisSseCoordinatorOptions { + redis: Redis; + channelPrefix?: string; + connectOnInit?: boolean; +} + +const DEFAULT_CHANNEL_PREFIX = 'a2a:sse'; + +export class RedisSseCoordinator { + private redis: Redis; + private subscriber: Redis | null = null; + private channelPrefix: string; + private handlers = new Map void>(); + private connecting: Promise | null = null; + + constructor(options: RedisSseCoordinatorOptions) { + this.redis = options.redis; + this.channelPrefix = options.channelPrefix ?? DEFAULT_CHANNEL_PREFIX; + + if (options.connectOnInit) { + this.connect().catch((err: unknown) => { + defaultLogger.warn({ err }, 'RedisSseCoordinator: failed to connect on init'); + }); + } + } + + private channelName(taskId: string): string { + return `${this.channelPrefix}:${taskId}`; + } + + async connect(): Promise { + if (this.subscriber) return; + if (this.connecting) return this.connecting; + + this.connecting = this._connect().finally(() => { + this.connecting = null; + }); + + return this.connecting; + } + + private async _connect(): Promise { + const sub = this.redis.duplicate(); + + sub.on('pmessage', (_pattern: string, channel: string, message: string) => { + const prefix = `${this.channelPrefix}:`; + if (!channel.startsWith(prefix)) return; + + const taskId = channel.slice(prefix.length); + const handler = this.handlers.get(taskId); + if (handler) { + try { + const data = JSON.parse(message); + handler(data); + } catch { + defaultLogger.debug({ channel, message }, 'RedisSseCoordinator: malformed JSON message'); + } + } + }); + + await sub.psubscribe(`${this.channelPrefix}:*`); + this.subscriber = sub; + } + + subscribe(taskId: string, handler: (data: unknown) => void): void { + this.handlers.set(taskId, handler); + } + + unsubscribe(taskId: string): void { + this.handlers.delete(taskId); + } + + async publish(taskId: string, data: unknown): Promise { + const message = JSON.stringify(data); + await this.redis.publish(this.channelName(taskId), message); + } + + async close(): Promise { + const sub = this.subscriber; + if (!sub) return; + + this.subscriber = null; + this.handlers.clear(); + + try { + await sub.punsubscribe(); + await sub.quit(); + } catch { + // ignore close errors + } + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7a17bf..7b3ca5a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,9 +174,33 @@ importers: packages/auth: dependencies: + '@reaatech/a2a-reference-core': + specifier: workspace:* + version: link:../core + '@reaatech/a2a-reference-observability': + specifier: workspace:* + version: link:../observability jose: specifier: ^6.0.10 version: 6.2.2 + zod: + specifier: ^3.24.2 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.6.0 + tsup: + specifier: ^8.4.0 + version: 8.5.1(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vitest: + specifier: ^3.1.1 + version: 3.2.4(@types/node@25.6.0)(tsx@4.21.0) + + packages/cli: devDependencies: '@types/node': specifier: ^25.6.0 @@ -221,10 +245,16 @@ importers: packages/core: dependencies: + jose: + specifier: ^6.0.10 + version: 6.2.2 zod: specifier: ^3.24.2 version: 3.25.76 devDependencies: + '@types/node': + specifier: ^25.6.0 + version: 25.6.0 tsup: specifier: ^8.4.0 version: 8.5.1(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3) @@ -293,13 +323,22 @@ importers: '@reaatech/a2a-reference-core': specifier: workspace:* version: link:../core + '@reaatech/a2a-reference-observability': + specifier: workspace:* + version: link:../observability ioredis: specifier: ^5.10.1 version: 5.10.1 + pg: + specifier: ^8.14.1 + version: 8.21.0 devDependencies: '@types/node': specifier: ^25.6.0 version: 25.6.0 + '@types/pg': + specifier: ^8.11.11 + version: 8.20.0 tsup: specifier: ^8.4.0 version: 8.5.1(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3) @@ -330,6 +369,9 @@ importers: hono: specifier: ^4.12.18 version: 4.12.19 + ioredis: + specifier: ^5.10.1 + version: 5.10.1 zod: specifier: ^3.24.2 version: 3.25.76 @@ -926,6 +968,9 @@ packages: '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/qs@6.15.0': resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} @@ -1766,6 +1811,40 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.13.0: + resolution: {integrity: sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.14.0: + resolution: {integrity: sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.21.0: + resolution: {integrity: sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1831,6 +1910,22 @@ packages: resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prettier@2.8.8: resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} engines: {node: '>=10.13.0'} @@ -2266,6 +2361,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -2804,6 +2903,12 @@ snapshots: dependencies: undici-types: 7.19.2 + '@types/pg@8.20.0': + dependencies: + '@types/node': 25.6.0 + pg-protocol: 1.14.0 + pg-types: 2.2.0 + '@types/qs@6.15.0': {} '@types/range-parser@1.2.7': {} @@ -3635,6 +3740,41 @@ snapshots: pathval@2.0.1: {} + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.13.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.21.0): + dependencies: + pg: 8.21.0 + + pg-protocol@1.14.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.21.0: + dependencies: + pg-connection-string: 2.13.0 + pg-pool: 3.14.0(pg@8.21.0) + pg-protocol: 1.14.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -3706,6 +3846,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prettier@2.8.8: {} process-warning@5.0.0: {} @@ -4187,6 +4337,8 @@ snapshots: wrappy@1.0.2: {} + xtend@4.0.2: {} + zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json index 7cef9c9..1787d21 100644 --- a/tsconfig.typecheck.json +++ b/tsconfig.typecheck.json @@ -9,7 +9,8 @@ "@reaatech/a2a-reference-auth": ["./packages/auth/src/index.ts"], "@reaatech/a2a-reference-observability": ["./packages/observability/src/index.ts"], "@reaatech/a2a-reference-persistence": ["./packages/persistence/src/index.ts"], - "@reaatech/a2a-reference-mcp-bridge": ["./packages/mcp-bridge/src/index.ts"] + "@reaatech/a2a-reference-mcp-bridge": ["./packages/mcp-bridge/src/index.ts"], + "@reaatech/a2a-reference-cli": ["./packages/cli/src/index.ts"] } } } diff --git a/typedoc.json b/typedoc.json new file mode 100644 index 0000000..3dc416e --- /dev/null +++ b/typedoc.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "name": "A2A Reference TypeScript", + "entryPoints": [ + "packages/core/src/index.ts", + "packages/server/src/index.ts", + "packages/client/src/index.ts", + "packages/auth/src/index.ts", + "packages/persistence/src/index.ts", + "packages/mcp-bridge/src/index.ts", + "packages/observability/src/index.ts" + ], + "entryPointStrategy": "expand", + "out": "docs/api", + "tsconfig": "tsconfig.json", + "includeVersion": true, + "excludePrivate": true, + "excludeProtected": true, + "excludeExternals": true, + "excludeInternal": true, + "categorizeByGroup": true, + "sort": ["source-order"], + "plugin": [], + "readme": "README.md" +}