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:
@@ -20,6 +20,7 @@ import { OrgRepository } from './repositories/OrgRepository.js';
|
|||||||
|
|
||||||
import { AuditService } from './services/AuditService.js';
|
import { AuditService } from './services/AuditService.js';
|
||||||
import { AgentService } from './services/AgentService.js';
|
import { AgentService } from './services/AgentService.js';
|
||||||
|
import { MarketplaceService } from './services/MarketplaceService.js';
|
||||||
import { CredentialService } from './services/CredentialService.js';
|
import { CredentialService } from './services/CredentialService.js';
|
||||||
import { OAuth2Service } from './services/OAuth2Service.js';
|
import { OAuth2Service } from './services/OAuth2Service.js';
|
||||||
import { OrgService } from './services/OrgService.js';
|
import { OrgService } from './services/OrgService.js';
|
||||||
@@ -33,6 +34,7 @@ import { WebhookDeliveryWorker } from './workers/WebhookDeliveryWorker.js';
|
|||||||
import { createKafkaProducer } from './adapters/KafkaAdapter.js';
|
import { createKafkaProducer } from './adapters/KafkaAdapter.js';
|
||||||
|
|
||||||
import { AgentController } from './controllers/AgentController.js';
|
import { AgentController } from './controllers/AgentController.js';
|
||||||
|
import { MarketplaceController } from './controllers/MarketplaceController.js';
|
||||||
import { TokenController } from './controllers/TokenController.js';
|
import { TokenController } from './controllers/TokenController.js';
|
||||||
import { CredentialController } from './controllers/CredentialController.js';
|
import { CredentialController } from './controllers/CredentialController.js';
|
||||||
import { AuditController } from './controllers/AuditController.js';
|
import { AuditController } from './controllers/AuditController.js';
|
||||||
@@ -44,6 +46,7 @@ import { WebhookController } from './controllers/WebhookController.js';
|
|||||||
import { ComplianceController } from './controllers/ComplianceController.js';
|
import { ComplianceController } from './controllers/ComplianceController.js';
|
||||||
|
|
||||||
import { createAgentsRouter } from './routes/agents.js';
|
import { createAgentsRouter } from './routes/agents.js';
|
||||||
|
import { createMarketplaceRouter } from './routes/marketplace.js';
|
||||||
import { createTokenRouter } from './routes/token.js';
|
import { createTokenRouter } from './routes/token.js';
|
||||||
import { createCredentialsRouter } from './routes/credentials.js';
|
import { createCredentialsRouter } from './routes/credentials.js';
|
||||||
import { createAuditRouter } from './routes/audit.js';
|
import { createAuditRouter } from './routes/audit.js';
|
||||||
@@ -176,6 +179,7 @@ export async function createApp(): Promise<Application> {
|
|||||||
const auditService = new AuditService(auditRepo);
|
const auditService = new AuditService(auditRepo);
|
||||||
const didService = new DIDService(pool, vaultClient, redis as RedisClientType, encryptionService);
|
const didService = new DIDService(pool, vaultClient, redis as RedisClientType, encryptionService);
|
||||||
const agentService = new AgentService(agentRepo, credentialRepo, auditService, didService, eventPublisher);
|
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 credentialService = new CredentialService(credentialRepo, agentRepo, auditService, vaultClient, eventPublisher, encryptionService);
|
||||||
const orgService = new OrgService(orgRepo, agentRepo);
|
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 federationService = new FederationService(pool, redis as RedisClientType);
|
||||||
const federationController = new FederationController(federationService);
|
const federationController = new FederationController(federationService);
|
||||||
const webhookController = new WebhookController(webhookService);
|
const webhookController = new WebhookController(webhookService);
|
||||||
|
const marketplaceController = new MarketplaceController(marketplaceService);
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
// Compliance services and background jobs (SOC 2 Type II)
|
// 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}`, createFederationRouter(federationController, authMiddleware, opaMiddleware));
|
||||||
app.use(`${API_BASE}/webhooks`, createWebhooksRouter(webhookController, authMiddleware, opaMiddleware));
|
app.use(`${API_BASE}/webhooks`, createWebhooksRouter(webhookController, authMiddleware, opaMiddleware));
|
||||||
app.use(`${API_BASE}`, createComplianceRouter(complianceController));
|
app.use(`${API_BASE}`, createComplianceRouter(complianceController));
|
||||||
|
app.use(`${API_BASE}/marketplace`, createMarketplaceRouter(marketplaceController));
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
// Dashboard static assets (served from dashboard/dist/)
|
// Dashboard static assets (served from dashboard/dist/)
|
||||||
|
|||||||
@@ -149,7 +149,17 @@ export class AgentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { agentId } = req.params;
|
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 ipAddress = req.ip ?? '0.0.0.0';
|
||||||
const userAgent = req.headers['user-agent'] ?? 'unknown';
|
const userAgent = req.headers['user-agent'] ?? 'unknown';
|
||||||
|
|
||||||
|
|||||||
75
src/controllers/MarketplaceController.ts
Normal file
75
src/controllers/MarketplaceController.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
7
src/db/migrations/021_add_agent_marketplace.sql
Normal file
7
src/db/migrations/021_add_agent_marketplace.sql
Normal 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;
|
||||||
@@ -147,3 +147,15 @@ export const dbPoolWaitingRequests = new Gauge({
|
|||||||
help: 'Current number of requests waiting for a PostgreSQL connection.',
|
help: 'Current number of requests waiting for a PostgreSQL connection.',
|
||||||
registers: [metricsRegistry],
|
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],
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { verifyToken } from '../utils/jwt.js';
|
|||||||
import { getRedisClient } from '../cache/redis.js';
|
import { getRedisClient } from '../cache/redis.js';
|
||||||
import { AuthenticationError } from '../utils/errors.js';
|
import { AuthenticationError } from '../utils/errors.js';
|
||||||
import { ITokenPayload } from '../types/index.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.
|
* Express middleware that validates a Bearer JWT token on every protected request.
|
||||||
@@ -78,6 +79,11 @@ export async function authMiddleware(
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.user = payload;
|
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();
|
next();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ICreateAgentRequest,
|
ICreateAgentRequest,
|
||||||
IUpdateAgentRequest,
|
IUpdateAgentRequest,
|
||||||
IAgentListFilters,
|
IAgentListFilters,
|
||||||
|
IMarketplaceFilters,
|
||||||
AgentStatus,
|
AgentStatus,
|
||||||
} from '../types/index.js';
|
} from '../types/index.js';
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ interface AgentRow {
|
|||||||
owner: string;
|
owner: string;
|
||||||
deployment_env: string;
|
deployment_env: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
is_public: boolean;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
/** W3C DID identifier — populated after DID generation (Phase 3). */
|
/** W3C DID identifier — populated after DID generation (Phase 3). */
|
||||||
@@ -49,6 +51,7 @@ function mapRowToAgent(row: AgentRow): IAgent {
|
|||||||
owner: row.owner,
|
owner: row.owner,
|
||||||
deploymentEnv: row.deployment_env as IAgent['deploymentEnv'],
|
deploymentEnv: row.deployment_env as IAgent['deploymentEnv'],
|
||||||
status: row.status as AgentStatus,
|
status: row.status as AgentStatus,
|
||||||
|
isPublic: row.is_public,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
did: row.did ?? undefined,
|
did: row.did ?? undefined,
|
||||||
@@ -208,6 +211,10 @@ export class AgentRepository {
|
|||||||
setClauses.push(`status = $${paramIndex++}`);
|
setClauses.push(`status = $${paramIndex++}`);
|
||||||
params.push(data.status);
|
params.push(data.status);
|
||||||
}
|
}
|
||||||
|
if (data.isPublic !== undefined) {
|
||||||
|
setClauses.push(`is_public = $${paramIndex++}`);
|
||||||
|
params.push(data.isPublic);
|
||||||
|
}
|
||||||
|
|
||||||
if (setClauses.length === 0) return null;
|
if (setClauses.length === 0) return null;
|
||||||
|
|
||||||
@@ -243,6 +250,74 @@ export class AgentRepository {
|
|||||||
return mapRowToAgent(result.rows[0]);
|
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).
|
* Counts all agents excluding decommissioned ones (for free-tier limit checks).
|
||||||
*
|
*
|
||||||
|
|||||||
57
src/routes/marketplace.ts
Normal file
57
src/routes/marketplace.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -50,6 +50,7 @@ interface AgentWithDIDKeyRow {
|
|||||||
key_type: string | null;
|
key_type: string | null;
|
||||||
curve: string | null;
|
curve: string | null;
|
||||||
key_created_at: Date | null;
|
key_created_at: Date | null;
|
||||||
|
is_public: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Result of the internal agent+key lookup. */
|
/** Result of the internal agent+key lookup. */
|
||||||
@@ -455,6 +456,7 @@ export class DIDService {
|
|||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
did: row.did ?? undefined,
|
did: row.did ?? undefined,
|
||||||
didCreatedAt: row.did_created_at ?? undefined,
|
didCreatedAt: row.did_created_at ?? undefined,
|
||||||
|
isPublic: row.is_public ?? false,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
106
src/services/MarketplaceService.ts
Normal file
106
src/services/MarketplaceService.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,8 @@ export interface IAgent {
|
|||||||
owner: string;
|
owner: string;
|
||||||
deploymentEnv: DeploymentEnv;
|
deploymentEnv: DeploymentEnv;
|
||||||
status: AgentStatus;
|
status: AgentStatus;
|
||||||
|
/** Whether this agent is listed in the public marketplace. Defaults to false. */
|
||||||
|
isPublic: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
/** W3C DID identifier for this agent. Populated after DID generation. */
|
/** W3C DID identifier for this agent. Populated after DID generation. */
|
||||||
@@ -104,6 +106,54 @@ export interface IUpdateAgentRequest {
|
|||||||
owner?: string;
|
owner?: string;
|
||||||
deploymentEnv?: DeploymentEnv;
|
deploymentEnv?: DeploymentEnv;
|
||||||
status?: AgentStatus;
|
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. */
|
/** Paginated list of agents. */
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export const updateAgentSchema = Joi.object({
|
|||||||
owner: Joi.string().min(1).max(128),
|
owner: Joi.string().min(1).max(128),
|
||||||
deploymentEnv: Joi.string().valid(...DEPLOYMENT_ENVS),
|
deploymentEnv: Joi.string().valid(...DEPLOYMENT_ENVS),
|
||||||
status: Joi.string().valid(...AGENT_STATUSES),
|
status: Joi.string().valid(...AGENT_STATUSES),
|
||||||
|
isPublic: Joi.boolean(),
|
||||||
})
|
})
|
||||||
.min(1)
|
.min(1)
|
||||||
.options({ allowUnknown: false });
|
.options({ allowUnknown: false });
|
||||||
@@ -135,3 +136,12 @@ export const auditQuerySchema = Joi.object({
|
|||||||
export const uuidParamSchema = Joi.object({
|
export const uuidParamSchema = Joi.object({
|
||||||
id: Joi.string().uuid().required(),
|
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),
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user