/** * mountA2aRequestHandler — DefaultRequestHandler + jsonRpcHandler を /a2a にマウント。 * * Plan 2A の base card route (`/.well-known/agent-card.json`) と * extended card route (`/a2a/agent-card/extended`) はそのまま残す。 * SDK の DefaultRequestHandler が `agent/getAuthenticatedExtendedCard` RPC を別途提供するが、 * Plan 2A の自前 REST route と競合しない(path が異なる)。 * 両者は同じ `buildExtendedCard` + `computeEffectiveScope` を呼ぶため実装ドリフトは起きない。 */ import express from 'express'; import type { Express } from 'express'; import { A2AError, DefaultRequestHandler, type ServerCallContext } from '@a2a-js/sdk/server'; import { jsonRpcHandler } from '@a2a-js/sdk/server/express'; import type Provider from 'oidc-provider'; import type { Repository } from '../../db/repository.js'; import { logger } from '../../logger.js'; import { SqliteA2aTaskStore } from './task-store.js'; import { MaestroA2aExecutor } from './executor.js'; import { buildBaseCard, buildExtendedCard } from './agent-card.js'; import { computeEffectiveScope } from './delegation.js'; import { makeA2aUserBuilder } from './user-builder.js'; import type { A2aPrincipal } from './token-auth.js'; import { A2aLimiter, resolveA2aLimits, type A2aLimits } from './limiter.js'; export interface MountA2aRequestHandlerDeps { provider: Provider; repo: Repository; baseUrl: string; issuer: string; version: string; limits?: Partial; } type CardDeps = { baseUrl: string; issuer: string; version: string }; /** * DefaultRequestHandler のサブクラス。 * `tasks/get`・`tasks/resubscribe`・`tasks/cancel` の 3 RPC に認証ゲートを追加する。 * * 委任を取り消されたクライアント(または未認証クライアント)は、 * すでに知っているタスク ID を使ってもタスクの状態・成果物を読み取れない(fail-closed)。 * context.user が存在しない・isAuthenticated !== true の場合は A2AError.invalidRequest を投げ、 * JsonRpcTransportHandler がそれを -32600 の JSON-RPC エラーレスポンスに変換する。 * * sendMessage / sendMessageStream はゲートしない:executor.execute() が * principal のチェックを行い、unauthorized ならタスクを failed 状態で終了させる(既存の保護)。 * * Task 7: limiter が注入された場合、認証チェックの後に rate-limit と * resubscribe スロットキャップを適用する(auth → rate → slot の順序を保証)。 */ export class AuthGatedRequestHandler extends DefaultRequestHandler { private limiter?: A2aLimiter; constructor( agentCard: ConstructorParameters[0], taskStore: ConstructorParameters[1], executor: ConstructorParameters[2], extendedCardProvider?: ConstructorParameters[6], limiter?: A2aLimiter, ) { super(agentCard, taskStore, executor, undefined, undefined, undefined, extendedCardProvider); this.limiter = limiter; } override async getTask(params: Parameters[0], context?: ServerCallContext) { this.requireAuthenticated(context); const grantId = this.meteredGrantId(context); if (this.limiter && !this.limiter.tryConsumeRate(grantId!)) { throw A2AError.invalidRequest('rate limit exceeded'); } return super.getTask(params, context); } override async cancelTask(params: Parameters[0], context?: ServerCallContext) { this.requireAuthenticated(context); const grantId = this.meteredGrantId(context); if (this.limiter && !this.limiter.tryConsumeRate(grantId!)) { throw A2AError.invalidRequest('rate limit exceeded'); } return super.cancelTask(params, context); } /** * 認証ゲートを同期的に実行してから super.resubscribe() を返す。 * 同期 throw にすることで、JsonRpcTransportHandler がジェネレータを取得する前に * エラーを捕捉できる(SSE ヘッダ送信前に正規の JSON-RPC エラーレスポンスを返せる)。 * * limiter 注入時: * 1. auth check (sync throw) * 2. rate check (sync throw) * 3. slot acquire (sync throw if cap reached) * 4. wrap inner generator — finally releases slot on any exit path */ override resubscribe(params: Parameters[0], context?: ServerCallContext) { // 1. Auth — must be first and synchronous this.requireAuthenticated(context); // Fail-closed: with a limiter active, an authenticated caller missing a grantId is denied. const grantId = this.meteredGrantId(context); // No limiter → unmetered (e.g. test without limiter / metering off) if (!this.limiter) { return super.resubscribe(params, context); } // 2. Rate limit (limiter present ⟹ grantId is defined, else meteredGrantId threw) if (!this.limiter.tryConsumeRate(grantId!)) { throw A2AError.invalidRequest('rate limit exceeded'); } // 3. Slot cap — synchronous acquire before returning generator if (!this.limiter.tryAcquireResubscribe(grantId!)) { throw A2AError.invalidRequest('too many concurrent resubscribe streams'); } // 4. Wrap inner generator with a real wall-clock race so the stream-duration cap // fires even when the inner stream emits no events (quiet-stream problem). // Release slot in finally on every exit path. const inner = super.resubscribe(params, context); const limiter = this.limiter; const maxMs = this.limiter.limits.maxStreamSeconds * 1000; async function* wrapped() { // Idempotent release: guards against the JS generator edge case where a consumer // calls .return() before the first .next() — in that case the generator body's // finally block does not execute. In practice the SDK SSE path always calls // .next() before .return(), so this window is unreachable in production. let released = false; function release() { if (!released) { released = true; limiter.releaseResubscribe(grantId!); } } const deadline = Date.now() + maxMs; const it = inner[Symbol.asyncIterator](); try { while (true) { const remaining = deadline - Date.now(); // Fire-and-forget the inner .return(): a `.return()` issued while a prior // `.next()` is still pending is queued behind it and does not settle until // that next() settles — which never happens for a quiet stream (the exact // case this cap targets). Awaiting it here would suspend `wrapped()` forever, // skip the `finally` below, and leak the resubscribe slot. Instead we drop the // slot immediately via `return` (→ finally → release) and let the inner // generator wind down on its own. if (remaining <= 0) { void it.return?.(undefined)?.catch(() => {}); return; } let timer: ReturnType | undefined; const timeout = new Promise<'timeout'>(res => { timer = setTimeout(() => res('timeout'), remaining); }); const step = await Promise.race([it.next(), timeout]).finally(() => { if (timer !== undefined) clearTimeout(timer); }); if (step === 'timeout') { void it.return?.(undefined)?.catch(() => {}); return; } if (step.done) return; yield step.value; } } finally { release(); } } return wrapped() as ReturnType; } private extractGrantId(context?: ServerCallContext): string | undefined { const p = (context?.user as any)?.a2aPrincipal as A2aPrincipal | undefined; return p?.grantId; } /** * Resolve the grantId for metering. When a limiter is active (a2a metering enabled), * an authenticated caller MUST carry a grantId — a missing one is treated as deny * (fail-closed), never as "skip the limits". Without a limiter (tests / metering off) * the caller is simply unmetered and undefined is returned. */ private meteredGrantId(context?: ServerCallContext): string | undefined { const grantId = this.extractGrantId(context); if (this.limiter && !grantId) { throw A2AError.invalidRequest('unauthorized: missing delegation grant'); } return grantId; } private requireAuthenticated(context?: ServerCallContext): void { const user = context?.user; if (!user || user.isAuthenticated !== true) { throw A2AError.invalidRequest( 'unauthorized: caller not authenticated or delegation has been revoked', ); } } } /** * Extended card provider(DefaultRequestHandler 7th 引数)。 * ServerCallContext.user.a2aPrincipal が存在すれば scope 計算後の extended card を返す。 * principal がない(未認証)または scope が null(unknown/inactive user)の場合は * base card を返す(スコープ情報の漏洩を防ぐ fail-closed 設計)。 * * テスト可能にするため named export にする。 */ export function makeExtendedCardProvider( repo: Repository, baseCard: ReturnType, deps: CardDeps, ) { return async (context?: ServerCallContext) => { const principal = (context?.user as any)?.a2aPrincipal as A2aPrincipal | undefined; if (!principal) return baseCard; // 未認証 → base card(no scope leak) try { const scope = await computeEffectiveScope(repo, principal); if (!scope) return baseCard; // unknown/inactive user → base card return buildExtendedCard(principal, { baseUrl: deps.baseUrl, issuer: deps.issuer, version: deps.version, userVisibleSpaceIds: scope.userVisibleSpaceIds, allowlistsBySpace: scope.allowlistsBySpace, }); } catch (err) { logger.warn(`[a2a] extended card provider failed: ${err}`); return baseCard; } }; } /** * DefaultRequestHandler + jsonRpcHandler を Express app の /a2a にマウントする。 * server.ts の `if (a2aCfg.enabled)` ブロック内から呼ぶこと。 * /a2a は /api 配下でないため requireAuth ブランケットに掛からない。 * 認証は makeA2aUserBuilder(UserBuilder)が担う。 */ export function mountA2aRequestHandler(app: Express, deps: MountA2aRequestHandlerDeps): void { const limits = resolveA2aLimits(deps.limits); const limiter = new A2aLimiter(limits); const taskStore = new SqliteA2aTaskStore(deps.repo); const executor = new MaestroA2aExecutor(deps.repo, { limiter }); const cardDeps: CardDeps = { baseUrl: deps.baseUrl, issuer: deps.issuer, version: deps.version, }; const baseCard = buildBaseCard(cardDeps); const extendedCardProvider = makeExtendedCardProvider(deps.repo, baseCard, cardDeps); const handler = new AuthGatedRequestHandler( baseCard, taskStore, executor, extendedCardProvider, // 4th arg: forwarded to super's 7th slot limiter, // 5th arg: shared rate/stream limiter (Task 7) ); const userBuilder = makeA2aUserBuilder(deps.provider, deps.repo); // body-size limit: must precede jsonRpcHandler so oversized bodies are rejected // before the SDK parser sees them. body-parser skips re-parsing when req.body is // already set, so the SDK's own express.json() inside jsonRpcHandler won't double-parse. app.use('/a2a', express.json({ limit: limits.maxPayloadBytes })); app.use('/a2a', jsonRpcHandler({ requestHandler: handler, userBuilder })); }