From 89c99b666da4384164f780ce8982cceb19639cf8 Mon Sep 17 00:00:00 2001 From: "SentryAgent.ai Developer" Date: Thu, 2 Apr 2026 10:17:51 +0000 Subject: [PATCH] =?UTF-8?q?feat(phase-4):=20WS4=20=E2=80=94=20Agent=20Mark?= =?UTF-8?q?etplace=20(public=20registry,=20pagination,=20filters)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/app.ts | 6 + src/controllers/AgentController.ts | 12 +- src/controllers/MarketplaceController.ts | 75 +++++++++++++ .../migrations/021_add_agent_marketplace.sql | 7 ++ src/metrics/registry.ts | 12 ++ src/middleware/auth.ts | 6 + src/repositories/AgentRepository.ts | 75 +++++++++++++ src/routes/marketplace.ts | 57 ++++++++++ src/services/DIDService.ts | 2 + src/services/MarketplaceService.ts | 106 ++++++++++++++++++ src/types/index.ts | 50 +++++++++ src/utils/validators.ts | 10 ++ 12 files changed, 417 insertions(+), 1 deletion(-) create mode 100644 src/controllers/MarketplaceController.ts create mode 100644 src/db/migrations/021_add_agent_marketplace.sql create mode 100644 src/routes/marketplace.ts create mode 100644 src/services/MarketplaceService.ts diff --git a/src/app.ts b/src/app.ts index 8c8e191..22b3480 100644 --- a/src/app.ts +++ b/src/app.ts @@ -20,6 +20,7 @@ import { OrgRepository } from './repositories/OrgRepository.js'; import { AuditService } from './services/AuditService.js'; import { AgentService } from './services/AgentService.js'; +import { MarketplaceService } from './services/MarketplaceService.js'; import { CredentialService } from './services/CredentialService.js'; import { OAuth2Service } from './services/OAuth2Service.js'; import { OrgService } from './services/OrgService.js'; @@ -33,6 +34,7 @@ import { WebhookDeliveryWorker } from './workers/WebhookDeliveryWorker.js'; import { createKafkaProducer } from './adapters/KafkaAdapter.js'; import { AgentController } from './controllers/AgentController.js'; +import { MarketplaceController } from './controllers/MarketplaceController.js'; import { TokenController } from './controllers/TokenController.js'; import { CredentialController } from './controllers/CredentialController.js'; import { AuditController } from './controllers/AuditController.js'; @@ -44,6 +46,7 @@ import { WebhookController } from './controllers/WebhookController.js'; import { ComplianceController } from './controllers/ComplianceController.js'; import { createAgentsRouter } from './routes/agents.js'; +import { createMarketplaceRouter } from './routes/marketplace.js'; import { createTokenRouter } from './routes/token.js'; import { createCredentialsRouter } from './routes/credentials.js'; import { createAuditRouter } from './routes/audit.js'; @@ -176,6 +179,7 @@ export async function createApp(): Promise { const auditService = new AuditService(auditRepo); const didService = new DIDService(pool, vaultClient, redis as RedisClientType, encryptionService); const agentService = new AgentService(agentRepo, credentialRepo, auditService, didService, eventPublisher); + const marketplaceService = new MarketplaceService(agentRepo); const credentialService = new CredentialService(credentialRepo, agentRepo, auditService, vaultClient, eventPublisher, encryptionService); const orgService = new OrgService(orgRepo, agentRepo); @@ -221,6 +225,7 @@ export async function createApp(): Promise { const federationService = new FederationService(pool, redis as RedisClientType); const federationController = new FederationController(federationService); const webhookController = new WebhookController(webhookService); + const marketplaceController = new MarketplaceController(marketplaceService); // ──────────────────────────────────────────────────────────────── // Compliance services and background jobs (SOC 2 Type II) @@ -270,6 +275,7 @@ export async function createApp(): Promise { app.use(`${API_BASE}`, createFederationRouter(federationController, authMiddleware, opaMiddleware)); app.use(`${API_BASE}/webhooks`, createWebhooksRouter(webhookController, authMiddleware, opaMiddleware)); app.use(`${API_BASE}`, createComplianceRouter(complianceController)); + app.use(`${API_BASE}/marketplace`, createMarketplaceRouter(marketplaceController)); // ──────────────────────────────────────────────────────────────── // Dashboard static assets (served from dashboard/dist/) diff --git a/src/controllers/AgentController.ts b/src/controllers/AgentController.ts index b11af03..b2b089d 100644 --- a/src/controllers/AgentController.ts +++ b/src/controllers/AgentController.ts @@ -149,7 +149,17 @@ export class AgentController { } const { agentId } = req.params; - const data = value as IUpdateAgentRequest; + /* eslint-disable @typescript-eslint/no-unsafe-member-access */ + const data: IUpdateAgentRequest = { + agentType: value.agentType as IUpdateAgentRequest['agentType'], + version: value.version as string | undefined, + capabilities: value.capabilities as string[] | undefined, + owner: value.owner as string | undefined, + deploymentEnv: value.deploymentEnv as IUpdateAgentRequest['deploymentEnv'], + status: value.status as IUpdateAgentRequest['status'], + isPublic: value.isPublic as boolean | undefined, + }; + /* eslint-enable @typescript-eslint/no-unsafe-member-access */ const ipAddress = req.ip ?? '0.0.0.0'; const userAgent = req.headers['user-agent'] ?? 'unknown'; diff --git a/src/controllers/MarketplaceController.ts b/src/controllers/MarketplaceController.ts new file mode 100644 index 0000000..3c5c5c0 --- /dev/null +++ b/src/controllers/MarketplaceController.ts @@ -0,0 +1,75 @@ +/** + * Marketplace Controller for SentryAgent.ai AgentIdP. + * HTTP handlers for unauthenticated public marketplace endpoints. + * No business logic — delegates to MarketplaceService. + */ + +import { Request, Response, NextFunction } from 'express'; +import { MarketplaceService } from '../services/MarketplaceService.js'; +import { marketplaceQuerySchema } from '../utils/validators.js'; +import { ValidationError } from '../utils/errors.js'; +import { IMarketplaceFilters } from '../types/index.js'; + +/** + * Controller for the public Agent Marketplace endpoints. + * Receives MarketplaceService via constructor injection. + */ +export class MarketplaceController { + /** + * @param marketplaceService - The marketplace service. + */ + constructor(private readonly marketplaceService: MarketplaceService) {} + + /** + * Handles GET /marketplace/agents — returns a paginated list of public agents. + * Unauthenticated. Supports ?q=, ?capability=, ?publisher= query filters. + * + * @param req - Express request with optional query filters. + * @param res - Express response. + * @param next - Express next function. + */ + listPublicAgents = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { error, value } = marketplaceQuerySchema.validate(req.query, { abortEarly: false }); + if (error) { + throw new ValidationError('Invalid query parameter value.', { + details: error.details.map((d) => ({ field: d.path.join('.'), reason: d.message })), + }); + } + + /* eslint-disable @typescript-eslint/no-unsafe-member-access */ + const filters: IMarketplaceFilters = { + page: value.page as number, + limit: value.limit as number, + q: value.q as string | undefined, + capability: value.capability as string | undefined, + publisher: value.publisher as string | undefined, + }; + /* eslint-enable @typescript-eslint/no-unsafe-member-access */ + + const result = await this.marketplaceService.listPublicAgents(filters); + res.status(200).json(result); + } catch (err) { + next(err); + } + }; + + /** + * Handles GET /marketplace/agents/:agentId — returns a single public agent with DID document. + * Unauthenticated. Returns 404 if the agent is private or inactive. + * + * @param req - Express request with agentId path param. + * @param res - Express response. + * @param next - Express next function. + */ + getPublicAgent = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { agentId } = req.params; + const agent = await this.marketplaceService.getPublicAgent(agentId); + res.status(200).json(agent); + } catch (err) { + next(err); + } + }; +} diff --git a/src/db/migrations/021_add_agent_marketplace.sql b/src/db/migrations/021_add_agent_marketplace.sql new file mode 100644 index 0000000..b101ede --- /dev/null +++ b/src/db/migrations/021_add_agent_marketplace.sql @@ -0,0 +1,7 @@ +-- Migration: 021_add_agent_marketplace +-- Adds is_public boolean column to agents table for Agent Marketplace feature. +-- Agents must explicitly opt-in to marketplace listing (default false). + +ALTER TABLE agents ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE INDEX IF NOT EXISTS idx_agents_is_public ON agents (is_public) WHERE is_public = TRUE; diff --git a/src/metrics/registry.ts b/src/metrics/registry.ts index f25b166..f63fcca 100644 --- a/src/metrics/registry.ts +++ b/src/metrics/registry.ts @@ -147,3 +147,15 @@ export const dbPoolWaitingRequests = new Gauge({ help: 'Current number of requests waiting for a PostgreSQL connection.', registers: [metricsRegistry], }); + +/** + * Total number of authenticated API calls per tenant. + * Incremented on every request that passes auth middleware successfully. + * Labels: tenant_id (organization_id from the JWT payload) + */ +export const tenantApiCallsTotal = new Counter({ + name: 'agentidp_tenant_api_calls_total', + help: 'Total number of authenticated API calls, labeled by tenant.', + labelNames: ['tenant_id'] as const, + registers: [metricsRegistry], +}); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 9b67207..44cdbf4 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -10,6 +10,7 @@ import { verifyToken } from '../utils/jwt.js'; import { getRedisClient } from '../cache/redis.js'; import { AuthenticationError } from '../utils/errors.js'; import { ITokenPayload } from '../types/index.js'; +import { tenantApiCallsTotal } from '../metrics/registry.js'; /** * Express middleware that validates a Bearer JWT token on every protected request. @@ -78,6 +79,11 @@ export async function authMiddleware( } req.user = payload; + + // Instrument: count authenticated API calls per tenant (WS4 — Agent Marketplace) + const tenantId = payload.organization_id ?? 'unknown'; + tenantApiCallsTotal.inc({ tenant_id: tenantId }); + next(); } catch (err) { next(err); diff --git a/src/repositories/AgentRepository.ts b/src/repositories/AgentRepository.ts index 8f0033f..aac4a87 100644 --- a/src/repositories/AgentRepository.ts +++ b/src/repositories/AgentRepository.ts @@ -10,6 +10,7 @@ import { ICreateAgentRequest, IUpdateAgentRequest, IAgentListFilters, + IMarketplaceFilters, AgentStatus, } from '../types/index.js'; @@ -24,6 +25,7 @@ interface AgentRow { owner: string; deployment_env: string; status: string; + is_public: boolean; created_at: Date; updated_at: Date; /** W3C DID identifier — populated after DID generation (Phase 3). */ @@ -49,6 +51,7 @@ function mapRowToAgent(row: AgentRow): IAgent { owner: row.owner, deploymentEnv: row.deployment_env as IAgent['deploymentEnv'], status: row.status as AgentStatus, + isPublic: row.is_public, createdAt: row.created_at, updatedAt: row.updated_at, did: row.did ?? undefined, @@ -208,6 +211,10 @@ export class AgentRepository { setClauses.push(`status = $${paramIndex++}`); params.push(data.status); } + if (data.isPublic !== undefined) { + setClauses.push(`is_public = $${paramIndex++}`); + params.push(data.isPublic); + } if (setClauses.length === 0) return null; @@ -243,6 +250,74 @@ export class AgentRepository { return mapRowToAgent(result.rows[0]); } + /** + * Finds a single public agent by its UUID (is_public = true, status = active). + * + * @param agentId - The agent UUID. + * @returns The agent record if it is public and active, otherwise null. + */ + async findPublicById(agentId: string): Promise { + const result: QueryResult = await this.pool.query( + `SELECT * FROM agents WHERE agent_id = $1 AND is_public = TRUE AND status = 'active'`, + [agentId], + ); + if (result.rows.length === 0) return null; + return mapRowToAgent(result.rows[0]); + } + + /** + * Returns a paginated list of public agents with optional marketplace filters. + * Only returns agents where is_public = true AND status = active. + * + * @param filters - Marketplace filter and pagination criteria. + * @returns Object containing the public agent list and total count. + */ + async findPublicAgents(filters: IMarketplaceFilters): Promise<{ agents: IAgent[]; total: number }> { + const conditions: string[] = [`is_public = TRUE`, `status = 'active'`]; + const params: unknown[] = []; + let paramIndex = 1; + + if (filters.publisher !== undefined) { + conditions.push(`owner = $${paramIndex++}`); + params.push(filters.publisher); + } + if (filters.capability !== undefined) { + conditions.push(`$${paramIndex++} = ANY(capabilities)`); + params.push(filters.capability); + } + if (filters.q !== undefined && filters.q.trim() !== '') { + const searchTerm = `%${filters.q.trim().toLowerCase()}%`; + conditions.push( + `(LOWER(owner) LIKE $${paramIndex} OR LOWER(agent_type) LIKE $${paramIndex} OR EXISTS (SELECT 1 FROM unnest(capabilities) AS c WHERE LOWER(c) LIKE $${paramIndex}))`, + ); + params.push(searchTerm); + paramIndex++; + } + + const whereClause = `WHERE ${conditions.join(' AND ')}`; + + const countResult: QueryResult<{ count: string }> = await this.pool.query( + `SELECT COUNT(*) as count FROM agents ${whereClause}`, + params, + ); + const total = parseInt(countResult.rows[0].count, 10); + + const offset = (filters.page - 1) * filters.limit; + const dataParams = [...params, filters.limit, offset]; + + const dataResult: QueryResult = await this.pool.query( + `SELECT * FROM agents ${whereClause} + ORDER BY created_at DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex}`, + dataParams, + ); + + return { + agents: dataResult.rows.map(mapRowToAgent), + total, + }; + } + /** * Counts all agents excluding decommissioned ones (for free-tier limit checks). * diff --git a/src/routes/marketplace.ts b/src/routes/marketplace.ts new file mode 100644 index 0000000..53fa7d2 --- /dev/null +++ b/src/routes/marketplace.ts @@ -0,0 +1,57 @@ +/** + * Marketplace routes for SentryAgent.ai AgentIdP. + * All routes are UNAUTHENTICATED — no auth or OPA middleware applied. + * Guarded by the MARKETPLACE_ENABLED feature flag (defaults to true). + * When MARKETPLACE_ENABLED=false, all routes return HTTP 404. + */ + +import { Router, Request, Response, NextFunction } from 'express'; +import { MarketplaceController } from '../controllers/MarketplaceController.js'; +import { asyncHandler } from '../utils/asyncHandler.js'; + +/** + * Middleware that returns 404 when the MARKETPLACE_ENABLED feature flag is disabled. + * The flag is considered enabled unless explicitly set to the string "false". + * + * @param _req - Express request (unused). + * @param res - Express response. + * @param next - Express next function. + */ +function marketplaceFeatureGuard(_req: Request, res: Response, next: NextFunction): void { + const enabled = process.env['MARKETPLACE_ENABLED']; + if (enabled === 'false') { + res.status(404).json({ + code: 'NOT_FOUND', + message: 'The Agent Marketplace is not enabled on this instance.', + }); + return; + } + next(); +} + +/** + * Creates and returns the Express router for public marketplace endpoints. + * + * @param marketplaceController - The marketplace controller instance. + * @returns Configured Express router. + */ +export function createMarketplaceRouter(marketplaceController: MarketplaceController): Router { + const router = Router(); + + // Apply feature flag guard to all marketplace routes + router.use(marketplaceFeatureGuard); + + // GET /marketplace/agents — list all public agents (unauthenticated, paginated) + router.get( + '/agents', + asyncHandler(marketplaceController.listPublicAgents.bind(marketplaceController)), + ); + + // GET /marketplace/agents/:agentId — get a public agent with DID document (unauthenticated) + router.get( + '/agents/:agentId', + asyncHandler(marketplaceController.getPublicAgent.bind(marketplaceController)), + ); + + return router; +} diff --git a/src/services/DIDService.ts b/src/services/DIDService.ts index 631471c..a8df884 100644 --- a/src/services/DIDService.ts +++ b/src/services/DIDService.ts @@ -50,6 +50,7 @@ interface AgentWithDIDKeyRow { key_type: string | null; curve: string | null; key_created_at: Date | null; + is_public: boolean | null; } /** Result of the internal agent+key lookup. */ @@ -455,6 +456,7 @@ export class DIDService { updatedAt: row.updated_at, did: row.did ?? undefined, didCreatedAt: row.did_created_at ?? undefined, + isPublic: row.is_public ?? false, }; return { diff --git a/src/services/MarketplaceService.ts b/src/services/MarketplaceService.ts new file mode 100644 index 0000000..433ad98 --- /dev/null +++ b/src/services/MarketplaceService.ts @@ -0,0 +1,106 @@ +/** + * Marketplace Service for SentryAgent.ai AgentIdP. + * Business logic for the public Agent Marketplace. + * Only exposes agents where is_public = true AND status = active. + */ + +import { AgentRepository } from '../repositories/AgentRepository.js'; +import { AgentNotFoundError } from '../utils/errors.js'; +import { + IMarketplaceFilters, + IMarketplaceAgentCard, + IPaginatedMarketplaceResponse, + IMinimalDIDDocument, + IAgent, +} from '../types/index.js'; + +/** + * Builds a minimal W3C DID Document from agent data when no key material is present. + * Used as a fallback when the agent has a DID assigned but no key record exists yet, + * or when constructing the document purely from agent identity data. + * + * @param agent - The agent record. + * @returns A minimal DID document, or null if the agent has no DID. + */ +function buildMinimalDIDDocument(agent: IAgent): IMinimalDIDDocument | null { + if (!agent.did) { + return null; + } + + return { + '@context': [ + 'https://www.w3.org/ns/did/v1', + 'https://w3id.org/security/suites/jws-2020/v1', + ], + id: agent.did, + controller: agent.did, + verificationMethod: [], + authentication: [], + }; +} + +/** + * Maps an IAgent record to the public IMarketplaceAgentCard shape. + * Strips all private fields (email, organizationId, internal status details). + * + * @param agent - The internal agent record. + * @returns The public-facing marketplace agent card. + */ +function mapAgentToCard(agent: IAgent): IMarketplaceAgentCard { + return { + agentId: agent.agentId, + agentType: agent.agentType, + version: agent.version, + capabilities: agent.capabilities, + owner: agent.owner, + deploymentEnv: agent.deploymentEnv, + did: agent.did ?? null, + didDocument: buildMinimalDIDDocument(agent), + publishedAt: agent.updatedAt, + }; +} + +/** + * Service for the public Agent Marketplace. + * All methods enforce the is_public = true constraint via the repository. + */ +export class MarketplaceService { + /** + * @param agentRepository - The agent data repository. + */ + constructor(private readonly agentRepository: AgentRepository) {} + + /** + * Returns a paginated, optionally filtered list of public agents. + * Supports free-text search (?q=), capability filter (?capability=), + * and publisher filter (?publisher=). + * + * @param filters - Marketplace filter and pagination criteria. + * @returns Paginated public agent card listing. + */ + async listPublicAgents(filters: IMarketplaceFilters): Promise { + const { agents, total } = await this.agentRepository.findPublicAgents(filters); + return { + data: agents.map(mapAgentToCard), + total, + page: filters.page, + limit: filters.limit, + }; + } + + /** + * Returns a single public agent with its DID document and agent card. + * Only succeeds when the agent is both public (is_public = true) and active. + * + * @param agentId - The agent UUID to retrieve. + * @returns The public marketplace agent card. + * @throws AgentNotFoundError if the agent does not exist, is private, or is not active. + */ + async getPublicAgent(agentId: string): Promise { + const agent = await this.agentRepository.findPublicById(agentId); + if (!agent) { + throw new AgentNotFoundError(agentId); + } + return mapAgentToCard(agent); + } +} diff --git a/src/types/index.ts b/src/types/index.ts index 3dfdad5..62b3420 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -77,6 +77,8 @@ export interface IAgent { owner: string; deploymentEnv: DeploymentEnv; status: AgentStatus; + /** Whether this agent is listed in the public marketplace. Defaults to false. */ + isPublic: boolean; createdAt: Date; updatedAt: Date; /** W3C DID identifier for this agent. Populated after DID generation. */ @@ -104,6 +106,54 @@ export interface IUpdateAgentRequest { owner?: string; deploymentEnv?: DeploymentEnv; status?: AgentStatus; + /** Set to true to list this agent in the public marketplace. */ + isPublic?: boolean; +} + +// ============================================================================ +// Agent Marketplace +// ============================================================================ + +/** Query filters for the public agent marketplace listing. */ +export interface IMarketplaceFilters { + /** Full-text search across owner, capabilities, and agent type. */ + q?: string; + /** Filter by a specific capability string. */ + capability?: string; + /** Filter by owner (publisher). */ + publisher?: string; + page: number; + limit: number; +} + +/** Minimal W3C DID Document returned with marketplace agent detail. */ +export interface IMinimalDIDDocument { + '@context': string[]; + id: string; + controller: string; + verificationMethod: unknown[]; + authentication: unknown[]; +} + +/** Agent card returned by the marketplace detail endpoint. */ +export interface IMarketplaceAgentCard { + agentId: string; + agentType: AgentType; + version: string; + capabilities: string[]; + owner: string; + deploymentEnv: DeploymentEnv; + did: string | null; + didDocument: IMinimalDIDDocument | null; + publishedAt: Date; +} + +/** Paginated marketplace listing response. */ +export interface IPaginatedMarketplaceResponse { + data: IMarketplaceAgentCard[]; + total: number; + page: number; + limit: number; } /** Paginated list of agents. */ diff --git a/src/utils/validators.ts b/src/utils/validators.ts index b63fb39..ce78b5c 100644 --- a/src/utils/validators.ts +++ b/src/utils/validators.ts @@ -71,6 +71,7 @@ export const updateAgentSchema = Joi.object({ owner: Joi.string().min(1).max(128), deploymentEnv: Joi.string().valid(...DEPLOYMENT_ENVS), status: Joi.string().valid(...AGENT_STATUSES), + isPublic: Joi.boolean(), }) .min(1) .options({ allowUnknown: false }); @@ -135,3 +136,12 @@ export const auditQuerySchema = Joi.object({ export const uuidParamSchema = Joi.object({ id: Joi.string().uuid().required(), }); + +/** Schema for GET /marketplace/agents query params. */ +export const marketplaceQuerySchema = Joi.object({ + page: Joi.number().integer().min(1).default(1), + limit: Joi.number().integer().min(1).max(100).default(20), + q: Joi.string().max(256), + capability: Joi.string().pattern(CAPABILITY_PATTERN), + publisher: Joi.string().max(128), +});