feat(phase-4): WS4 — Agent Marketplace (public registry, pagination, filters)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SentryAgent.ai Developer
2026-04-02 10:17:51 +00:00
parent d1e6af25aa
commit 89c99b666d
12 changed files with 417 additions and 1 deletions

View File

@@ -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<Application> {
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<Application> {
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<Application> {
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/)

View File

@@ -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';

View File

@@ -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<void> => {
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<void> => {
try {
const { agentId } = req.params;
const agent = await this.marketplaceService.getPublicAgent(agentId);
res.status(200).json(agent);
} catch (err) {
next(err);
}
};
}

View File

@@ -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;

View File

@@ -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],
});

View File

@@ -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);

View File

@@ -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<IAgent | null> {
const result: QueryResult<AgentRow> = 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<AgentRow> = 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).
*

57
src/routes/marketplace.ts Normal file
View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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<IPaginatedMarketplaceResponse> {
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<IMarketplaceAgentCard> {
const agent = await this.agentRepository.findPublicById(agentId);
if (!agent) {
throw new AgentNotFoundError(agentId);
}
return mapAgentToCard(agent);
}
}

View File

@@ -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. */

View File

@@ -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),
});