feat(phase-6): WS3+WS4+WS6 — Analytics, API Tiers, AGNTCY Compliance
WS3 — Advanced Analytics Dashboard: - DB migration: analytics_events table (tenant_id, date, metric_type, count) - AnalyticsService: recordEvent (fire-and-forget), getTokenTrend, getAgentActivity, getAgentUsageSummary - Analytics hooks in OAuth2Service (token_issued) and AgentService (agent_registered/deactivated) - AnalyticsController + routes/analytics.ts (gated by ANALYTICS_ENABLED flag) - Portal: TokenTrendChart (recharts LineChart), AgentHeatmap (recharts heatmap), /analytics page WS4 — API Gateway Tiers: - DB migration: tenant_tiers table; src/config/tiers.ts (free/pro/enterprise limits) - TierService: getStatus, initiateUpgrade (Stripe), applyUpgrade; TierLimitError in errors.ts - tierEnforcement middleware (Redis-backed daily call/token counters; TIER_ENFORCEMENT flag) - Agent count enforcement in AgentService.create() - Stripe webhook updated to call TierService.applyUpgrade() on checkout.session.completed - TierController + routes/tiers.ts; Portal: /settings/tier page with upgrade flow WS6 — AGNTCY Compliance Certification: - ComplianceService: generateReport() (Redis-cached 5 min), exportAgentCards() - Compliance sections: agent-identity (DID + credential expiry checks), audit-trail (Merkle chain) - ComplianceController updated with getComplianceReport, exportAgentCards handlers - routes/compliance.ts: new AGNTCY routes (gated by COMPLIANCE_ENABLED flag); SOC2 routes unaffected QA: - 28 new unit tests: AnalyticsService (8), TierService (9), ComplianceService (11) — all pass - 673 total unit tests passing; 0 TypeScript errors across API and portal - AGNTCY conformance test suite at tests/agntcy-conformance/ (4 protocol tests) - Portal builds cleanly: 9 routes including /analytics and /settings/tier - Feature flags verified: ANALYTICS_ENABLED, TIER_ENFORCEMENT, COMPLIANCE_ENABLED Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
54
src/app.ts
54
src/app.ts
@@ -21,6 +21,7 @@ import { OrgRepository } from './repositories/OrgRepository.js';
|
||||
|
||||
import { AuditService } from './services/AuditService.js';
|
||||
import { AgentService } from './services/AgentService.js';
|
||||
import { AnalyticsService } from './services/AnalyticsService.js';
|
||||
import { MarketplaceService } from './services/MarketplaceService.js';
|
||||
import { BillingService } from './services/BillingService.js';
|
||||
import { UsageService } from './services/UsageService.js';
|
||||
@@ -36,6 +37,7 @@ import { EventPublisher } from './services/EventPublisher.js';
|
||||
import { WebhookDeliveryWorker } from './workers/WebhookDeliveryWorker.js';
|
||||
import { createKafkaProducer } from './adapters/KafkaAdapter.js';
|
||||
|
||||
import { AnalyticsController } from './controllers/AnalyticsController.js';
|
||||
import { AgentController } from './controllers/AgentController.js';
|
||||
import { MarketplaceController } from './controllers/MarketplaceController.js';
|
||||
import { BillingController } from './controllers/BillingController.js';
|
||||
@@ -50,7 +52,9 @@ import { OIDCController } from './controllers/OIDCController.js';
|
||||
import { FederationController } from './controllers/FederationController.js';
|
||||
import { WebhookController } from './controllers/WebhookController.js';
|
||||
import { ComplianceController } from './controllers/ComplianceController.js';
|
||||
import { ComplianceService } from './services/ComplianceService.js';
|
||||
|
||||
import { createAnalyticsRouter } from './routes/analytics.js';
|
||||
import { createAgentsRouter } from './routes/agents.js';
|
||||
import { createMarketplaceRouter } from './routes/marketplace.js';
|
||||
import { createBillingRouter } from './routes/billing.js';
|
||||
@@ -74,6 +78,9 @@ import { DelegationController } from './controllers/DelegationController.js';
|
||||
import { createScaffoldRouter } from './routes/scaffold.js';
|
||||
import { ScaffoldService } from './services/ScaffoldService.js';
|
||||
import { ScaffoldController } from './controllers/ScaffoldController.js';
|
||||
import { TierService } from './services/TierService.js';
|
||||
import { TierController } from './controllers/TierController.js';
|
||||
import { createTiersRouter } from './routes/tiers.js';
|
||||
|
||||
import { errorHandler } from './middleware/errorHandler.js';
|
||||
import { createOpaMiddleware } from './middleware/opa.js';
|
||||
@@ -81,7 +88,7 @@ import { metricsMiddleware } from './middleware/metrics.js';
|
||||
import { createOrgContextMiddleware } from './middleware/orgContext.js';
|
||||
import { authMiddleware } from './middleware/auth.js';
|
||||
import { createUsageMeteringMiddleware, startUsageMeteringFlush } from './middleware/usageMeteringMiddleware.js';
|
||||
import { createFreeTierEnforcementMiddleware } from './middleware/freeTierEnforcementMiddleware.js';
|
||||
import { createTierEnforcementMiddleware } from './middleware/tierEnforcement.js';
|
||||
import { tlsEnforcementMiddleware } from './middleware/TLSEnforcementMiddleware.js';
|
||||
import { createVaultClientFromEnv } from './vault/VaultClient.js';
|
||||
import { getEncryptionService } from './services/EncryptionService.js';
|
||||
@@ -191,12 +198,25 @@ export async function createApp(): Promise<Application> {
|
||||
webhookWorker.start();
|
||||
const eventPublisher = new EventPublisher(webhookWorker, pool, kafkaProducer);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Stripe client + TierService — created early so both BillingService
|
||||
// and AgentService can receive TierService via constructor injection.
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const stripe = new Stripe(process.env['STRIPE_SECRET_KEY'] ?? '', { apiVersion: '2026-03-25.dahlia' });
|
||||
const tierService = new TierService(pool, redis as RedisClientType, stripe);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Service layer
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const auditService = new AuditService(auditRepo);
|
||||
const didService = new DIDService(pool, vaultClient, redis as RedisClientType, encryptionService);
|
||||
const agentService = new AgentService(agentRepo, credentialRepo, auditService, didService, eventPublisher);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Phase 6 WS3: Analytics Service
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const analyticsService = new AnalyticsService(pool);
|
||||
|
||||
const agentService = new AgentService(agentRepo, credentialRepo, auditService, didService, eventPublisher, analyticsService, tierService);
|
||||
const marketplaceService = new MarketplaceService(agentRepo);
|
||||
const credentialService = new CredentialService(credentialRepo, agentRepo, auditService, vaultClient, eventPublisher, encryptionService);
|
||||
const orgService = new OrgService(orgRepo, agentRepo);
|
||||
@@ -223,6 +243,7 @@ export async function createApp(): Promise<Application> {
|
||||
idTokenService,
|
||||
eventPublisher,
|
||||
encryptionService,
|
||||
analyticsService,
|
||||
);
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
@@ -234,6 +255,7 @@ export async function createApp(): Promise<Application> {
|
||||
// Controller layer
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const agentController = new AgentController(agentService);
|
||||
const analyticsController = new AnalyticsController(analyticsService);
|
||||
const tokenController = new TokenController(oauth2Service);
|
||||
const credentialController = new CredentialController(credentialService);
|
||||
const auditController = new AuditController(auditService);
|
||||
@@ -248,8 +270,7 @@ export async function createApp(): Promise<Application> {
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Billing & Usage Metering (WS6)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const stripe = new Stripe(process.env['STRIPE_SECRET_KEY'] ?? '', { apiVersion: '2026-03-25.dahlia' });
|
||||
const billingService = new BillingService(pool, stripe);
|
||||
const billingService = new BillingService(pool, stripe, tierService);
|
||||
const usageService = new UsageService(pool);
|
||||
const billingController = new BillingController(billingService, usageService);
|
||||
|
||||
@@ -265,7 +286,8 @@ export async function createApp(): Promise<Application> {
|
||||
// Compliance services and background jobs (SOC 2 Type II)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const auditVerificationService = getAuditVerificationService(pool);
|
||||
const complianceController = new ComplianceController(auditVerificationService);
|
||||
const complianceService = new ComplianceService(pool, redis as RedisClientType);
|
||||
const complianceController = new ComplianceController(auditVerificationService, complianceService);
|
||||
|
||||
// Start background compliance monitoring jobs (non-blocking)
|
||||
startSecretsRotationJob(pool);
|
||||
@@ -285,10 +307,12 @@ export async function createApp(): Promise<Application> {
|
||||
app.use(createUsageMeteringMiddleware(pool));
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Free tier enforcement — rejects requests exceeding free plan limits
|
||||
// Applied after usage metering and before routes.
|
||||
// Tier enforcement — Redis-backed daily API call rate limits per
|
||||
// tenant tier (free/pro/enterprise). Runs after auth; skipped when
|
||||
// TIER_ENFORCEMENT=false or for enterprise tenants. Supersedes
|
||||
// the legacy freeTierEnforcementMiddleware (removed Phase 6 WS4).
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
app.use(createFreeTierEnforcementMiddleware(pool, redis as RedisClientType));
|
||||
app.use(createTierEnforcementMiddleware(pool, redis as RedisClientType));
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Routes
|
||||
@@ -326,6 +350,12 @@ export async function createApp(): Promise<Application> {
|
||||
// Billing & Usage Metering — checkout, webhook, usage summary
|
||||
app.use(`${API_BASE}/billing`, createBillingRouter(billingController, authMiddleware));
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Phase 6 WS4: Tier management — status and upgrade endpoints
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
const tierController = new TierController(tierService);
|
||||
app.use(`${API_BASE}/tiers`, createTiersRouter(tierController, authMiddleware));
|
||||
|
||||
// OIDC trust-policy management (authenticated) and token exchange (unauthenticated)
|
||||
// Both routers mount under ${API_BASE}/oidc — trust-policy routes use /trust-policies prefix,
|
||||
// token exchange uses /token, so there are no path conflicts.
|
||||
@@ -341,6 +371,14 @@ export async function createApp(): Promise<Application> {
|
||||
app.use(`${API_BASE}`, createDelegationRouter(delegationController, authMiddleware));
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Phase 6 WS3: Analytics (guarded by ANALYTICS_ENABLED flag)
|
||||
// When disabled, all /api/v1/analytics/* routes return 404.
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
if (process.env['ANALYTICS_ENABLED'] !== 'false') {
|
||||
app.use(`${API_BASE}/analytics`, createAnalyticsRouter(analyticsController, authMiddleware));
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Phase 5 WS5: Scaffold Generator
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
53
src/config/tiers.ts
Normal file
53
src/config/tiers.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Tier configuration for SentryAgent.ai AgentIdP.
|
||||
* TIER_CONFIG is the single source of truth for all per-tier limits.
|
||||
* Never duplicate these values — always import from here.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Per-tier limit definitions.
|
||||
* `Infinity` signals no enforcement for enterprise tenants.
|
||||
*/
|
||||
export const TIER_CONFIG = {
|
||||
free: {
|
||||
/** Maximum number of non-decommissioned agents allowed per org. */
|
||||
maxAgents: 10,
|
||||
/** Maximum number of API calls allowed per calendar day (UTC). */
|
||||
maxCallsPerDay: 1_000,
|
||||
/** Maximum number of token issuances allowed per calendar day (UTC). */
|
||||
maxTokensPerDay: 1_000,
|
||||
},
|
||||
pro: {
|
||||
maxAgents: 100,
|
||||
maxCallsPerDay: 50_000,
|
||||
maxTokensPerDay: 50_000,
|
||||
},
|
||||
enterprise: {
|
||||
maxAgents: Infinity,
|
||||
maxCallsPerDay: Infinity,
|
||||
maxTokensPerDay: Infinity,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** Union type of valid tier names derived from TIER_CONFIG keys. */
|
||||
export type TierName = keyof typeof TIER_CONFIG;
|
||||
|
||||
/**
|
||||
* Ordered tier rank used to validate upgrade direction.
|
||||
* Higher index = higher tier.
|
||||
*/
|
||||
export const TIER_RANK: Record<TierName, number> = {
|
||||
free: 0,
|
||||
pro: 1,
|
||||
enterprise: 2,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Returns true when the supplied string is a valid TierName.
|
||||
*
|
||||
* @param value - The string to test.
|
||||
* @returns Type predicate narrowing value to TierName.
|
||||
*/
|
||||
export function isTierName(value: string): value is TierName {
|
||||
return value in TIER_CONFIG;
|
||||
}
|
||||
113
src/controllers/AnalyticsController.ts
Normal file
113
src/controllers/AnalyticsController.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Analytics Controller for SentryAgent.ai AgentIdP.
|
||||
* HTTP handlers for tenant analytics endpoints.
|
||||
* No business logic — delegates all data access to AnalyticsService.
|
||||
* All handlers enforce tenant scoping via req.user.organization_id.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { AnalyticsService } from '../services/AnalyticsService.js';
|
||||
import { AuthenticationError, ValidationError } from '../utils/errors.js';
|
||||
|
||||
/** Maximum permitted value for the `days` query parameter. */
|
||||
const MAX_DAYS = 90;
|
||||
/** Default number of days returned when `days` is not specified. */
|
||||
const DEFAULT_DAYS = 30;
|
||||
|
||||
/**
|
||||
* Controller for the analytics endpoints.
|
||||
* Receives AnalyticsService via constructor injection.
|
||||
*/
|
||||
export class AnalyticsController {
|
||||
/**
|
||||
* @param analyticsService - The analytics data service.
|
||||
*/
|
||||
constructor(private readonly analyticsService: AnalyticsService) {}
|
||||
|
||||
/**
|
||||
* Handles GET /analytics/tokens — returns daily token issuance trend.
|
||||
* Query parameter `days` (optional, default 30, max 90).
|
||||
* Responds 400 if `days` exceeds the maximum.
|
||||
* Responds 401 if the request is not authenticated.
|
||||
*
|
||||
* @param req - Express request. Must have req.user populated.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
getTokenTrend = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
const daysParam = req.query['days'];
|
||||
const days = daysParam !== undefined ? parseInt(String(daysParam), 10) : DEFAULT_DAYS;
|
||||
|
||||
if (isNaN(days) || days < 1) {
|
||||
throw new ValidationError('Query parameter `days` must be a positive integer.', {
|
||||
field: 'days',
|
||||
});
|
||||
}
|
||||
|
||||
if (days > MAX_DAYS) {
|
||||
throw new ValidationError(
|
||||
`Query parameter \`days\` must not exceed ${MAX_DAYS}.`,
|
||||
{ field: 'days', max: MAX_DAYS, provided: days },
|
||||
);
|
||||
}
|
||||
|
||||
const tenantId = req.user.organization_id ?? 'org_system';
|
||||
const trend = await this.analyticsService.getTokenTrend(tenantId, days);
|
||||
|
||||
res.status(200).json(trend);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles GET /analytics/agents/activity — returns agent activity heatmap data.
|
||||
* Responds 401 if the request is not authenticated.
|
||||
*
|
||||
* @param req - Express request. Must have req.user populated.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
getAgentActivity = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
const tenantId = req.user.organization_id ?? 'org_system';
|
||||
const activity = await this.analyticsService.getAgentActivity(tenantId);
|
||||
|
||||
res.status(200).json(activity);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles GET /analytics/agents — returns per-agent usage summary for the current month.
|
||||
* Responds 401 if the request is not authenticated.
|
||||
*
|
||||
* @param req - Express request. Must have req.user populated.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
getAgentSummary = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
const tenantId = req.user.organization_id ?? 'org_system';
|
||||
const summary = await this.analyticsService.getAgentUsageSummary(tenantId);
|
||||
|
||||
res.status(200).json(summary);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
/**
|
||||
* ComplianceController — SOC 2 Type II compliance endpoints.
|
||||
* ComplianceController — SOC 2 Type II and AGNTCY compliance endpoints.
|
||||
*
|
||||
* Handles two endpoints defined in docs/openapi/compliance.yaml:
|
||||
* GET /api/v1/audit/verify — Audit chain integrity verification (auth required)
|
||||
* Handles endpoints defined in docs/openapi/compliance.yaml:
|
||||
* GET /api/v1/audit/verify — Audit chain integrity verification (auth required)
|
||||
* GET /api/v1/compliance/controls — SOC 2 control status summary (public)
|
||||
* GET /api/v1/compliance/report — AGNTCY compliance report (auth required)
|
||||
* GET /api/v1/compliance/agent-cards — AGNTCY agent card export (auth required)
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { AuditVerificationService } from '../services/AuditVerificationService.js';
|
||||
import { ComplianceService } from '../services/ComplianceService.js';
|
||||
import { getAllControlStatuses } from '../services/ComplianceStatusStore.js';
|
||||
import { ValidationError } from '../utils/errors.js';
|
||||
import { ITokenPayload } from '../types/index.js';
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
@@ -33,15 +37,18 @@ function isValidIsoDateTime(value: string): boolean {
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Controller for SOC 2 Type II compliance API endpoints.
|
||||
* Exposes audit chain verification and live control status reporting.
|
||||
* Controller for SOC 2 Type II and AGNTCY compliance API endpoints.
|
||||
* Exposes audit chain verification, live control status reporting,
|
||||
* AGNTCY compliance report generation, and agent card export.
|
||||
*/
|
||||
export class ComplianceController {
|
||||
/**
|
||||
* @param auditVerificationService - Service for cryptographic audit chain verification.
|
||||
* @param complianceService - Service for AGNTCY compliance report and agent card generation.
|
||||
*/
|
||||
constructor(
|
||||
private readonly auditVerificationService: AuditVerificationService,
|
||||
private readonly complianceService: ComplianceService,
|
||||
) {}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
@@ -127,4 +134,59 @@ export class ComplianceController {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/compliance/report
|
||||
*
|
||||
* Generates and returns an AGNTCY compliance report for the authenticated tenant.
|
||||
* The report covers agent-identity verification and audit-trail integrity.
|
||||
* Reports are cached in Redis for 5 minutes; sets `X-Cache: HIT` when served from cache.
|
||||
*
|
||||
* Requires Bearer token authentication (tenant extracted from req.user.sub).
|
||||
*
|
||||
* @param req - Express request; tenant derived from authenticated user context.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
async getComplianceReport(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const user = req.user as ITokenPayload | undefined;
|
||||
const tenantId = user?.organization_id ?? user?.sub ?? '';
|
||||
|
||||
const report = await this.complianceService.generateReport(tenantId);
|
||||
|
||||
if (report.from_cache === true) {
|
||||
res.setHeader('X-Cache', 'HIT');
|
||||
}
|
||||
|
||||
res.status(200).json(report);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/v1/compliance/agent-cards
|
||||
*
|
||||
* Exports all active agents for the authenticated tenant as AGNTCY-standard
|
||||
* agent card JSON objects.
|
||||
*
|
||||
* Requires Bearer token authentication (tenant extracted from req.user.sub).
|
||||
*
|
||||
* @param req - Express request; tenant derived from authenticated user context.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
async exportAgentCards(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
const user = req.user as ITokenPayload | undefined;
|
||||
const tenantId = user?.organization_id ?? user?.sub ?? '';
|
||||
|
||||
const cards = await this.complianceService.exportAgentCards(tenantId);
|
||||
|
||||
res.status(200).json(cards);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
93
src/controllers/TierController.ts
Normal file
93
src/controllers/TierController.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Tier Controller for SentryAgent.ai AgentIdP.
|
||||
* HTTP handlers for tier status and upgrade endpoints.
|
||||
* No business logic — delegates entirely to TierService.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { TierService } from '../services/TierService.js';
|
||||
import { AuthenticationError, ValidationError } from '../utils/errors.js';
|
||||
import { isTierName } from '../config/tiers.js';
|
||||
|
||||
/**
|
||||
* Controller for tenant tier management endpoints.
|
||||
* Receives TierService via constructor injection.
|
||||
*/
|
||||
export class TierController {
|
||||
/**
|
||||
* @param tierService - The tier management service.
|
||||
*/
|
||||
constructor(private readonly tierService: TierService) {}
|
||||
|
||||
/**
|
||||
* Handles GET /api/tiers/status — returns the current tier, limits, and usage.
|
||||
*
|
||||
* Response: 200 ITierStatus
|
||||
* Errors: 401 when unauthenticated.
|
||||
*
|
||||
* @param req - Express request. Must have req.user populated.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
getStatus = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
const orgId = req.user.organization_id;
|
||||
if (!orgId) {
|
||||
throw new AuthenticationError('organization_id is required in token.');
|
||||
}
|
||||
|
||||
const status = await this.tierService.getStatus(orgId);
|
||||
res.status(200).json(status);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles POST /api/tiers/upgrade — initiates a Stripe checkout session for a tier upgrade.
|
||||
*
|
||||
* Request body: { target_tier: 'pro' | 'enterprise' }
|
||||
* Response: 200 { checkoutUrl: string }
|
||||
* Errors: 400 when target_tier is missing/invalid or is not an upgrade.
|
||||
* 401 when unauthenticated.
|
||||
*
|
||||
* @param req - Express request. Must have req.user populated.
|
||||
* @param res - Express response.
|
||||
* @param next - Express next function.
|
||||
*/
|
||||
initiateUpgrade = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
const orgId = req.user.organization_id;
|
||||
if (!orgId) {
|
||||
throw new AuthenticationError('organization_id is required in token.');
|
||||
}
|
||||
|
||||
const body = req.body as { target_tier?: unknown };
|
||||
const rawTargetTier = body.target_tier;
|
||||
|
||||
if (!rawTargetTier || typeof rawTargetTier !== 'string') {
|
||||
throw new ValidationError('target_tier is required.', { received: rawTargetTier });
|
||||
}
|
||||
|
||||
if (!isTierName(rawTargetTier)) {
|
||||
throw new ValidationError(
|
||||
`target_tier must be one of: free, pro, enterprise.`,
|
||||
{ received: rawTargetTier },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.tierService.initiateUpgrade(orgId, rawTargetTier);
|
||||
res.status(200).json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
14
src/db/migrations/025_add_analytics_events.sql
Normal file
14
src/db/migrations/025_add_analytics_events.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- Migration: 025_add_analytics_events
|
||||
-- Creates the analytics_events table for daily pre-aggregated event rollups.
|
||||
-- Each row represents one (tenant, date, metric_type) bucket with a running count.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS analytics_events (
|
||||
organization_id VARCHAR(40) NOT NULL REFERENCES organizations(organization_id) ON DELETE CASCADE,
|
||||
date DATE NOT NULL,
|
||||
metric_type VARCHAR(50) NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (organization_id, date, metric_type)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_analytics_events_org_date
|
||||
ON analytics_events(organization_id, date);
|
||||
21
src/db/migrations/026_add_tenant_tiers.sql
Normal file
21
src/db/migrations/026_add_tenant_tiers.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- Migration 026: Add tenant tier tracking columns to organizations table
|
||||
-- Phase 6, WS4 — API Gateway Tiers
|
||||
--
|
||||
-- Adds a dedicated `tier` column (ENUM: free/pro/enterprise) and a `tier_updated_at`
|
||||
-- timestamp column. The existing `plan_tier` VARCHAR column is retained for
|
||||
-- backward compatibility with the billing/subscription subsystem.
|
||||
|
||||
CREATE TYPE IF NOT EXISTS tier_type AS ENUM ('free', 'pro', 'enterprise');
|
||||
|
||||
ALTER TABLE organizations
|
||||
ADD COLUMN IF NOT EXISTS tier tier_type NOT NULL DEFAULT 'free';
|
||||
|
||||
ALTER TABLE organizations
|
||||
ADD COLUMN IF NOT EXISTS tier_updated_at TIMESTAMPTZ;
|
||||
|
||||
-- Backfill tier from plan_tier for existing rows so the new column is consistent.
|
||||
UPDATE organizations
|
||||
SET tier = plan_tier::tier_type
|
||||
WHERE tier = 'free' AND plan_tier IN ('free', 'pro', 'enterprise');
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_organizations_tier ON organizations(tier);
|
||||
162
src/middleware/tierEnforcement.ts
Normal file
162
src/middleware/tierEnforcement.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Tier enforcement middleware for SentryAgent.ai AgentIdP.
|
||||
*
|
||||
* Enforces per-tenant daily API call limits based on the tenant's tier.
|
||||
* Uses Redis keys `rate:tier:calls:<org_id>` with TTL aligned to UTC midnight.
|
||||
*
|
||||
* Behaviour:
|
||||
* - Skipped entirely when TIER_ENFORCEMENT env var is 'false'.
|
||||
* - Skipped for enterprise tenants (no limits apply).
|
||||
* - On Redis unavailability: logs the error and proceeds (fail-open).
|
||||
* - Sets X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset on every response.
|
||||
* - Returns HTTP 429 with Retry-After header when limit is exceeded.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
import type { RedisClientType } from 'redis';
|
||||
import { Pool } from 'pg';
|
||||
import { TIER_CONFIG, TierName, isTierName } from '../config/tiers.js';
|
||||
import { TierLimitError } from '../utils/errors.js';
|
||||
|
||||
/** Redis key prefix for daily API call counters. */
|
||||
const CALLS_KEY_PREFIX = 'rate:tier:calls:';
|
||||
|
||||
/**
|
||||
* Returns the number of seconds remaining until the next UTC midnight.
|
||||
* Used as the Redis key TTL and the Retry-After value on rejection.
|
||||
*
|
||||
* @returns Seconds until next UTC midnight (minimum 1).
|
||||
*/
|
||||
function secondsUntilUtcMidnight(): number {
|
||||
const now = Date.now();
|
||||
const midnight = new Date();
|
||||
midnight.setUTCHours(24, 0, 0, 0);
|
||||
return Math.max(1, Math.ceil((midnight.getTime() - now) / 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Unix timestamp (seconds) of the next UTC midnight.
|
||||
* Used for the X-RateLimit-Reset header.
|
||||
*
|
||||
* @returns Unix timestamp of next UTC midnight.
|
||||
*/
|
||||
function nextUtcMidnightTimestamp(): number {
|
||||
const midnight = new Date();
|
||||
midnight.setUTCHours(24, 0, 0, 0);
|
||||
return Math.ceil(midnight.getTime() / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the tenant's current tier from the organizations table.
|
||||
* Falls back to 'free' when the tenant row is not found.
|
||||
*
|
||||
* @param pool - PostgreSQL connection pool.
|
||||
* @param orgId - The organization ID.
|
||||
* @returns The tenant's current TierName.
|
||||
*/
|
||||
async function getTenantTier(pool: Pool, orgId: string): Promise<TierName> {
|
||||
const result = await pool.query<{ tier: string }>(
|
||||
`SELECT tier FROM organizations WHERE organization_id = $1 LIMIT 1`,
|
||||
[orgId],
|
||||
);
|
||||
if (result.rows.length === 0) return 'free';
|
||||
const tier = result.rows[0].tier;
|
||||
return isTierName(tier) ? tier : 'free';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the tier enforcement middleware.
|
||||
*
|
||||
* Designed to run after auth middleware (req.user must be populated).
|
||||
* Unauthenticated requests pass through unaffected.
|
||||
*
|
||||
* @param pool - PostgreSQL connection pool (used to look up tenant tier).
|
||||
* @param redis - Redis client (used for rate counter storage).
|
||||
* @returns Express RequestHandler.
|
||||
*/
|
||||
export function createTierEnforcementMiddleware(
|
||||
pool: Pool,
|
||||
redis: RedisClientType,
|
||||
): RequestHandler {
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
// Feature flag: bypass all tier enforcement when disabled
|
||||
if (process.env['TIER_ENFORCEMENT'] === 'false') {
|
||||
// Still set headers reflecting unlimited limits
|
||||
res.setHeader('X-RateLimit-Limit', 'unlimited');
|
||||
res.setHeader('X-RateLimit-Remaining', 'unlimited');
|
||||
res.setHeader('X-RateLimit-Reset', nextUtcMidnightTimestamp());
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Only enforce for authenticated requests
|
||||
if (!req.user?.organization_id) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const orgId = req.user.organization_id;
|
||||
|
||||
void (async (): Promise<void> => {
|
||||
try {
|
||||
const tier = await getTenantTier(pool, orgId);
|
||||
|
||||
// Enterprise tenants bypass all limits
|
||||
if (tier === 'enterprise') {
|
||||
res.setHeader('X-RateLimit-Limit', 'unlimited');
|
||||
res.setHeader('X-RateLimit-Remaining', 'unlimited');
|
||||
res.setHeader('X-RateLimit-Reset', nextUtcMidnightTimestamp());
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const limit = TIER_CONFIG[tier].maxCallsPerDay;
|
||||
const redisKey = `${CALLS_KEY_PREFIX}${orgId}`;
|
||||
const ttl = secondsUntilUtcMidnight();
|
||||
const resetAt = nextUtcMidnightTimestamp();
|
||||
|
||||
let currentCount: number;
|
||||
|
||||
try {
|
||||
// Atomically increment and set TTL aligned to UTC midnight.
|
||||
// INCR returns the new value after increment.
|
||||
const newCount = await redis.incr(redisKey);
|
||||
currentCount = newCount;
|
||||
|
||||
// Set TTL only on the first increment to avoid resetting the window
|
||||
// on every request. If the key was brand new, newCount === 1.
|
||||
if (newCount === 1) {
|
||||
await redis.expire(redisKey, ttl);
|
||||
}
|
||||
} catch (redisErr) {
|
||||
// Redis unavailable — fail-open: log and proceed without rate limiting
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[tierEnforcement] Redis error — proceeding fail-open:', redisErr);
|
||||
res.setHeader('X-RateLimit-Limit', limit);
|
||||
res.setHeader('X-RateLimit-Remaining', 0);
|
||||
res.setHeader('X-RateLimit-Reset', resetAt);
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
const remaining = Math.max(0, limit - currentCount);
|
||||
|
||||
// Set rate limit headers on all responses
|
||||
res.setHeader('X-RateLimit-Limit', limit);
|
||||
res.setHeader('X-RateLimit-Remaining', remaining);
|
||||
res.setHeader('X-RateLimit-Reset', resetAt);
|
||||
|
||||
// Reject if the new count exceeds the limit
|
||||
if (currentCount > limit) {
|
||||
res.setHeader('Retry-After', ttl);
|
||||
next(new TierLimitError('API call', limit, { orgId, tier }));
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
})();
|
||||
};
|
||||
}
|
||||
51
src/routes/analytics.ts
Normal file
51
src/routes/analytics.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Analytics routes for SentryAgent.ai AgentIdP.
|
||||
* Exposes tenant analytics endpoints under /api/v1/analytics.
|
||||
* All routes require a valid Bearer JWT (authMiddleware).
|
||||
*/
|
||||
|
||||
import { Router, RequestHandler } from 'express';
|
||||
import { AnalyticsController } from '../controllers/AnalyticsController.js';
|
||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||
|
||||
/**
|
||||
* Creates and returns the Express router for analytics endpoints.
|
||||
*
|
||||
* Routes:
|
||||
* GET /analytics/tokens — daily token issuance trend (last N days)
|
||||
* GET /analytics/agents/activity — agent activity heatmap by dow+hour
|
||||
* GET /analytics/agents — per-agent usage summary for current month
|
||||
*
|
||||
* @param analyticsController - The analytics controller instance.
|
||||
* @param authMiddleware - The JWT authentication middleware for all protected endpoints.
|
||||
* @returns Configured Express router.
|
||||
*/
|
||||
export function createAnalyticsRouter(
|
||||
analyticsController: AnalyticsController,
|
||||
authMiddleware: RequestHandler,
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// All analytics routes require authentication
|
||||
router.use(authMiddleware);
|
||||
|
||||
// GET /analytics/tokens — daily token issuance trend
|
||||
router.get(
|
||||
'/tokens',
|
||||
asyncHandler(analyticsController.getTokenTrend.bind(analyticsController)),
|
||||
);
|
||||
|
||||
// GET /analytics/agents/activity — agent activity heatmap (must be registered before /agents)
|
||||
router.get(
|
||||
'/agents/activity',
|
||||
asyncHandler(analyticsController.getAgentActivity.bind(analyticsController)),
|
||||
);
|
||||
|
||||
// GET /analytics/agents — per-agent usage summary
|
||||
router.get(
|
||||
'/agents',
|
||||
asyncHandler(analyticsController.getAgentSummary.bind(analyticsController)),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
/**
|
||||
* Compliance routes for SentryAgent.ai AgentIdP.
|
||||
* Mounts the SOC 2 Type II compliance endpoints:
|
||||
* GET /api/v1/audit/verify — Audit chain integrity (requires audit:read)
|
||||
*
|
||||
* SOC 2 Type II routes (always active):
|
||||
* GET /api/v1/audit/verify — Audit chain integrity (requires audit:read)
|
||||
* GET /api/v1/compliance/controls — SOC 2 control status (public, no auth)
|
||||
*
|
||||
* AGNTCY compliance routes (gated by COMPLIANCE_ENABLED env var):
|
||||
* GET /api/v1/compliance/report — AGNTCY compliance report (requires auth)
|
||||
* GET /api/v1/compliance/agent-cards — AGNTCY agent card export (requires auth)
|
||||
*
|
||||
* When COMPLIANCE_ENABLED=false, the AGNTCY routes return 404.
|
||||
* The SOC 2 routes are never gated.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
@@ -108,16 +116,22 @@ async function auditRateLimiter(
|
||||
/**
|
||||
* Creates and returns the Express router for compliance endpoints.
|
||||
*
|
||||
* Routes:
|
||||
* GET /audit/verify — Verify audit chain integrity (Bearer + audit:read scope)
|
||||
* SOC 2 routes (always mounted, never gated):
|
||||
* GET /audit/verify — Verify audit chain integrity (Bearer + audit:read scope)
|
||||
* GET /compliance/controls — Get SOC 2 control status (public, no auth required)
|
||||
*
|
||||
* AGNTCY routes (mounted only when COMPLIANCE_ENABLED != 'false'):
|
||||
* GET /compliance/report — AGNTCY compliance report (Bearer auth required)
|
||||
* GET /compliance/agent-cards — AGNTCY agent card export (Bearer auth required)
|
||||
*
|
||||
* @param complianceController - The compliance controller instance.
|
||||
* @returns Configured Express router.
|
||||
*/
|
||||
export function createComplianceRouter(complianceController: ComplianceController): Router {
|
||||
const router = Router();
|
||||
|
||||
// ── SOC 2 routes — always active ──────────────────────────────────────────
|
||||
|
||||
// GET /audit/verify — requires authentication + audit:read scope + rate limit
|
||||
router.get(
|
||||
'/audit/verify',
|
||||
@@ -133,5 +147,23 @@ export function createComplianceRouter(complianceController: ComplianceControlle
|
||||
asyncHandler(complianceController.getComplianceControls.bind(complianceController)),
|
||||
);
|
||||
|
||||
// ── AGNTCY compliance routes — gated by COMPLIANCE_ENABLED flag ───────────
|
||||
|
||||
if (process.env['COMPLIANCE_ENABLED'] !== 'false') {
|
||||
// GET /compliance/report — requires Bearer auth; returns AGNTCY compliance report
|
||||
router.get(
|
||||
'/compliance/report',
|
||||
asyncHandler(authMiddleware),
|
||||
asyncHandler(complianceController.getComplianceReport.bind(complianceController)),
|
||||
);
|
||||
|
||||
// GET /compliance/agent-cards — requires Bearer auth; returns AGNTCY agent card array
|
||||
router.get(
|
||||
'/compliance/agent-cards',
|
||||
asyncHandler(authMiddleware),
|
||||
asyncHandler(complianceController.exportAgentCards.bind(complianceController)),
|
||||
);
|
||||
}
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
42
src/routes/tiers.ts
Normal file
42
src/routes/tiers.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Tier routes for SentryAgent.ai AgentIdP.
|
||||
* Mounts tier status and upgrade endpoints under /api/tiers.
|
||||
*/
|
||||
|
||||
import { Router, RequestHandler } from 'express';
|
||||
import { TierController } from '../controllers/TierController.js';
|
||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||
|
||||
/**
|
||||
* Creates and returns the Express router for tier management endpoints.
|
||||
*
|
||||
* Routes:
|
||||
* GET /tiers/status — authenticated; returns current tier, limits, and usage
|
||||
* POST /tiers/upgrade — authenticated; initiates a Stripe checkout for a tier upgrade
|
||||
*
|
||||
* @param controller - The tier controller instance.
|
||||
* @param authMiddleware - The JWT authentication middleware.
|
||||
* @returns Configured Express router.
|
||||
*/
|
||||
export function createTiersRouter(
|
||||
controller: TierController,
|
||||
authMiddleware: RequestHandler,
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// GET /tiers/status — returns tier, limits, and live usage counters
|
||||
router.get(
|
||||
'/status',
|
||||
authMiddleware,
|
||||
asyncHandler(controller.getStatus.bind(controller)),
|
||||
);
|
||||
|
||||
// POST /tiers/upgrade — initiates Stripe checkout for a tier upgrade
|
||||
router.post(
|
||||
'/upgrade',
|
||||
authMiddleware,
|
||||
asyncHandler(controller.initiateUpgrade.bind(controller)),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { CredentialRepository } from '../repositories/CredentialRepository.js';
|
||||
import { AuditService } from './AuditService.js';
|
||||
import { DIDService } from './DIDService.js';
|
||||
import { EventPublisher } from './EventPublisher.js';
|
||||
import { AnalyticsService } from './AnalyticsService.js';
|
||||
import {
|
||||
IAgent,
|
||||
ICreateAgentRequest,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
FreeTierLimitError,
|
||||
} from '../utils/errors.js';
|
||||
import { agentsRegisteredTotal } from '../metrics/registry.js';
|
||||
import { TierService } from './TierService.js';
|
||||
|
||||
const FREE_TIER_MAX_AGENTS = 100;
|
||||
|
||||
@@ -39,6 +41,10 @@ export class AgentService {
|
||||
* (backward-compatible default).
|
||||
* @param eventPublisher - Optional EventPublisher. When provided, lifecycle events are
|
||||
* published as webhooks and Kafka messages (fire-and-forget).
|
||||
* @param analyticsService - Optional AnalyticsService. When provided, agent_registered
|
||||
* and agent_deactivated events are recorded fire-and-forget.
|
||||
* @param tierService - Optional TierService. When provided, per-tier agent count limits
|
||||
* are enforced at agent creation time (Phase 6 WS4).
|
||||
*/
|
||||
constructor(
|
||||
private readonly agentRepository: AgentRepository,
|
||||
@@ -46,6 +52,8 @@ export class AgentService {
|
||||
private readonly auditService: AuditService,
|
||||
private readonly didService: DIDService | null = null,
|
||||
private readonly eventPublisher: EventPublisher | null = null,
|
||||
private readonly analyticsService: AnalyticsService | null = null,
|
||||
private readonly tierService: TierService | null = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -64,7 +72,17 @@ export class AgentService {
|
||||
ipAddress: string,
|
||||
userAgent: string,
|
||||
): Promise<IAgent> {
|
||||
// Enforce free-tier agent count limit
|
||||
const orgId = data.organizationId ?? 'org_system';
|
||||
|
||||
// ── Tier-based agent count enforcement (Phase 6 WS4) ────────────────────
|
||||
// When TierService is available and TIER_ENFORCEMENT is enabled, validate
|
||||
// the per-tier agent limit for the requesting organization.
|
||||
if (this.tierService !== null && process.env['TIER_ENFORCEMENT'] !== 'false') {
|
||||
const tier = await this.tierService.fetchTier(orgId);
|
||||
await this.tierService.enforceAgentLimit(orgId, tier);
|
||||
}
|
||||
|
||||
// Enforce legacy free-tier agent count limit (global across all orgs)
|
||||
const currentCount = await this.agentRepository.countActive();
|
||||
if (currentCount >= FREE_TIER_MAX_AGENTS) {
|
||||
throw new FreeTierLimitError(
|
||||
@@ -83,8 +101,7 @@ export class AgentService {
|
||||
|
||||
// Generate a W3C DID for the new agent when DIDService is available
|
||||
if (this.didService !== null) {
|
||||
const organizationId = data.organizationId ?? 'org_system';
|
||||
await this.didService.generateDIDForAgent(agent.agentId, organizationId);
|
||||
await this.didService.generateDIDForAgent(agent.agentId, orgId);
|
||||
}
|
||||
|
||||
// Synchronous audit insert
|
||||
@@ -100,6 +117,17 @@ export class AgentService {
|
||||
// Instrument: count successful agent registrations
|
||||
agentsRegisteredTotal.inc({ deployment_env: data.deploymentEnv });
|
||||
|
||||
// Analytics: record agent_registered event (fire-and-forget)
|
||||
if (this.analyticsService !== null) {
|
||||
void this.analyticsService.recordEvent(
|
||||
agent.organizationId ?? 'org_system',
|
||||
'agent_registered',
|
||||
).catch((err: unknown) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[AgentService] analytics record (agent_registered) failed', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Publish event (fire-and-forget)
|
||||
void this.eventPublisher?.publishEvent(
|
||||
agent.organizationId,
|
||||
@@ -263,6 +291,17 @@ export class AgentService {
|
||||
{},
|
||||
);
|
||||
|
||||
// Analytics: record agent_deactivated event (fire-and-forget)
|
||||
if (this.analyticsService !== null) {
|
||||
void this.analyticsService.recordEvent(
|
||||
agent.organizationId ?? 'org_system',
|
||||
'agent_deactivated',
|
||||
).catch((err: unknown) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[AgentService] analytics record (agent_deactivated) failed', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Publish event (fire-and-forget)
|
||||
void this.eventPublisher?.publishEvent(
|
||||
agent.organizationId,
|
||||
|
||||
185
src/services/AnalyticsService.ts
Normal file
185
src/services/AnalyticsService.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Analytics Service for SentryAgent.ai AgentIdP.
|
||||
* Records daily aggregated analytics events and exposes query methods
|
||||
* for token trends, agent activity heatmaps, and per-agent usage summaries.
|
||||
*
|
||||
* All query methods scope results strictly to the supplied tenantId.
|
||||
* The recordEvent method is fire-and-forget — it catches all errors internally
|
||||
* and never propagates them to the caller.
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
|
||||
/** A single date-bucketed token count entry. */
|
||||
export interface ITokenTrendEntry {
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** Agent activity bucketed by day-of-week and hour-of-day. */
|
||||
export interface IAgentActivityEntry {
|
||||
agent_id: string;
|
||||
dow: number;
|
||||
hour: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** Per-agent token issuance summary for the current calendar month. */
|
||||
export interface IAgentUsageSummaryEntry {
|
||||
agent_id: string;
|
||||
name: string;
|
||||
token_count: number;
|
||||
}
|
||||
|
||||
/** Maximum number of days allowed for trend queries. */
|
||||
const MAX_TREND_DAYS = 90;
|
||||
|
||||
/**
|
||||
* Service for recording and querying tenant analytics events.
|
||||
* Analytics writes are fire-and-forget and never block primary request paths.
|
||||
*/
|
||||
export class AnalyticsService {
|
||||
/**
|
||||
* @param pool - The PostgreSQL connection pool.
|
||||
*/
|
||||
constructor(private readonly pool: Pool) {}
|
||||
|
||||
/**
|
||||
* Records a single analytics event for a tenant by upserting a daily counter row.
|
||||
* This method is fire-and-forget: it catches all errors, logs them, and never throws.
|
||||
*
|
||||
* @param tenantId - The organization_id of the tenant.
|
||||
* @param metricType - The event type (e.g. 'token_issued', 'agent_registered').
|
||||
* @returns Promise that resolves when the upsert completes (or is silently swallowed on error).
|
||||
*/
|
||||
async recordEvent(tenantId: string, metricType: string): Promise<void> {
|
||||
try {
|
||||
await this.pool.query(
|
||||
`INSERT INTO analytics_events (organization_id, date, metric_type, count)
|
||||
VALUES ($1, CURRENT_DATE, $2, 1)
|
||||
ON CONFLICT (organization_id, date, metric_type)
|
||||
DO UPDATE SET count = analytics_events.count + 1`,
|
||||
[tenantId, metricType],
|
||||
);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[AnalyticsService] recordEvent failed — primary path unaffected', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns daily token issuance counts for the last N days (max 90).
|
||||
* Days with no recorded events are filled in with a count of 0.
|
||||
*
|
||||
* @param tenantId - The organization_id of the tenant.
|
||||
* @param days - Number of days to look back (1–90).
|
||||
* @returns Array of date/count entries sorted ascending by date.
|
||||
*/
|
||||
async getTokenTrend(tenantId: string, days: number): Promise<ITokenTrendEntry[]> {
|
||||
const clampedDays = Math.min(days, MAX_TREND_DAYS);
|
||||
|
||||
// Generate a complete date series and left-join analytics data so that
|
||||
// days with no events appear as 0.
|
||||
const result = await this.pool.query<{ date: string; count: string }>(
|
||||
`SELECT
|
||||
gs.date::DATE::TEXT AS date,
|
||||
COALESCE(ae.count, 0)::INTEGER AS count
|
||||
FROM generate_series(
|
||||
CURRENT_DATE - ($1::INTEGER - 1) * INTERVAL '1 day',
|
||||
CURRENT_DATE,
|
||||
INTERVAL '1 day'
|
||||
) AS gs(date)
|
||||
LEFT JOIN analytics_events ae
|
||||
ON ae.date = gs.date::DATE
|
||||
AND ae.organization_id = $2
|
||||
AND ae.metric_type = 'token_issued'
|
||||
ORDER BY gs.date ASC`,
|
||||
[clampedDays, tenantId],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
date: row.date,
|
||||
count: Number(row.count),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns agent activity bucketed by day-of-week (0=Sun…6=Sat) and hour-of-day
|
||||
* for the last 30 days. Only metric_types that include an agent_id prefix
|
||||
* (format: 'agent:<agentId>:<metricType>') are included.
|
||||
*
|
||||
* Since analytics_events stores daily aggregates, DOW/hour granularity is derived
|
||||
* from the event date at day resolution (hour defaults to 0 for daily rollups).
|
||||
*
|
||||
* @param tenantId - The organization_id of the tenant.
|
||||
* @returns Array of activity bucket entries sorted by agent_id, dow, hour.
|
||||
*/
|
||||
async getAgentActivity(tenantId: string): Promise<IAgentActivityEntry[]> {
|
||||
const result = await this.pool.query<{
|
||||
agent_id: string;
|
||||
dow: string;
|
||||
hour: string;
|
||||
count: string;
|
||||
}>(
|
||||
`SELECT
|
||||
SPLIT_PART(ae.metric_type, ':', 2) AS agent_id,
|
||||
EXTRACT(DOW FROM ae.date)::INTEGER::TEXT AS dow,
|
||||
0::TEXT AS hour,
|
||||
SUM(ae.count)::TEXT AS count
|
||||
FROM analytics_events ae
|
||||
WHERE ae.organization_id = $1
|
||||
AND ae.date >= CURRENT_DATE - INTERVAL '30 days'
|
||||
AND ae.metric_type LIKE 'agent:%:%'
|
||||
GROUP BY
|
||||
SPLIT_PART(ae.metric_type, ':', 2),
|
||||
EXTRACT(DOW FROM ae.date)
|
||||
ORDER BY agent_id, dow`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
agent_id: row.agent_id,
|
||||
dow: Number(row.dow),
|
||||
hour: Number(row.hour),
|
||||
count: Number(row.count),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns per-agent token issuance totals for the current calendar month,
|
||||
* joined with the agent name from the agents table.
|
||||
* Results are sorted descending by token_count.
|
||||
*
|
||||
* @param tenantId - The organization_id of the tenant.
|
||||
* @returns Array of agent usage summary entries.
|
||||
*/
|
||||
async getAgentUsageSummary(tenantId: string): Promise<IAgentUsageSummaryEntry[]> {
|
||||
const result = await this.pool.query<{
|
||||
agent_id: string;
|
||||
name: string;
|
||||
token_count: string;
|
||||
}>(
|
||||
`SELECT
|
||||
a.agent_id,
|
||||
a.owner AS name,
|
||||
COALESCE(SUM(ae.count), 0)::INTEGER AS token_count
|
||||
FROM agents a
|
||||
LEFT JOIN analytics_events ae
|
||||
ON ae.organization_id = a.organization_id
|
||||
AND ae.metric_type = 'token_issued'
|
||||
AND ae.date >= DATE_TRUNC('month', CURRENT_DATE)
|
||||
AND ae.date < DATE_TRUNC('month', CURRENT_DATE) + INTERVAL '1 month'
|
||||
WHERE a.organization_id = $1
|
||||
AND a.status != 'decommissioned'
|
||||
GROUP BY a.agent_id, a.owner
|
||||
ORDER BY token_count DESC`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
agent_id: row.agent_id,
|
||||
name: row.name,
|
||||
token_count: Number(row.token_count),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import Stripe from 'stripe';
|
||||
import { TierService } from './TierService.js';
|
||||
import { isTierName } from '../config/tiers.js';
|
||||
|
||||
/**
|
||||
* Current subscription status for a tenant.
|
||||
@@ -36,10 +38,13 @@ export class BillingService {
|
||||
/**
|
||||
* @param pool - PostgreSQL connection pool.
|
||||
* @param stripe - Configured Stripe client instance.
|
||||
* @param tierService - Optional TierService. When provided, tier upgrades are applied
|
||||
* when a checkout.session.completed event carries tier metadata.
|
||||
*/
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
private readonly stripe: Stripe,
|
||||
private readonly tierService: TierService | null = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -101,6 +106,14 @@ export class BillingService {
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
await this.upsertSubscription(subscription);
|
||||
}
|
||||
|
||||
// ── Tier upgrade via checkout session ────────────────────────────────────
|
||||
// When a checkout session is completed and the session metadata contains
|
||||
// { orgId, targetTier }, apply the tier upgrade to the organizations table.
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
await this.applyTierUpgradeIfPresent(session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,6 +150,28 @@ export class BillingService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a tier upgrade when the checkout session metadata contains
|
||||
* the required fields (`orgId` and `targetTier`).
|
||||
* Skips silently when metadata is absent, incomplete, or TierService is not wired.
|
||||
*
|
||||
* @param session - The completed Stripe Checkout Session.
|
||||
*/
|
||||
private async applyTierUpgradeIfPresent(session: Stripe.Checkout.Session): Promise<void> {
|
||||
if (this.tierService === null) return;
|
||||
|
||||
const metadata = session.metadata;
|
||||
if (!metadata) return;
|
||||
|
||||
const orgId = metadata['orgId'];
|
||||
const targetTier = metadata['targetTier'];
|
||||
|
||||
if (!orgId || !targetTier) return;
|
||||
if (!isTierName(targetTier)) return;
|
||||
|
||||
await this.tierService.applyUpgrade(orgId, targetTier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upserts a Stripe subscription into tenant_subscriptions.
|
||||
* Resolves the tenant from the subscription's customer.
|
||||
|
||||
359
src/services/ComplianceService.ts
Normal file
359
src/services/ComplianceService.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* ComplianceService — AGNTCY Compliance Report generation and Agent Card export.
|
||||
*
|
||||
* Builds multi-section compliance reports covering agent identity verification
|
||||
* and audit trail integrity, and exports all active agents as AGNTCY-standard
|
||||
* agent card JSON. Reports are cached in Redis for 5 minutes to avoid
|
||||
* repeated expensive DB queries.
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import type { RedisClientType } from 'redis';
|
||||
import { AuditVerificationService } from './AuditVerificationService.js';
|
||||
|
||||
// ============================================================================
|
||||
// Report interfaces
|
||||
// ============================================================================
|
||||
|
||||
/** Status value for a compliance check section. */
|
||||
export type ComplianceStatus = 'pass' | 'fail' | 'warn';
|
||||
|
||||
/**
|
||||
* A single named compliance check section within a full report.
|
||||
*/
|
||||
export interface IComplianceSection {
|
||||
/** Human-readable section identifier (e.g. 'agent-identity', 'audit-trail'). */
|
||||
name: string;
|
||||
/** Aggregate status for this section. */
|
||||
status: ComplianceStatus;
|
||||
/** Human-readable detail string describing the check result. */
|
||||
details: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full AGNTCY compliance report for a single tenant.
|
||||
* Returned by generateReport() and cached in Redis.
|
||||
*/
|
||||
export interface IComplianceReport {
|
||||
/** ISO 8601 timestamp of when this report was generated. */
|
||||
generated_at: string;
|
||||
/** The tenant (organization) this report covers. */
|
||||
tenant_id: string;
|
||||
/** AGNTCY schema version this report conforms to. */
|
||||
agntcy_schema_version: string;
|
||||
/** Ordered list of named compliance sections. */
|
||||
sections: IComplianceSection[];
|
||||
/** Rolled-up overall status across all sections. */
|
||||
overall_status: ComplianceStatus;
|
||||
/** Present and true when the report was served from Redis cache. */
|
||||
from_cache?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agent card interface
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* AGNTCY-standard agent card export for a single agent.
|
||||
*/
|
||||
export interface IAgentCard {
|
||||
/** DID:WEB identifier for the agent, or the raw agent_id if no DID is set. */
|
||||
id: string;
|
||||
/** Human-readable name (owner field from the agents table). */
|
||||
name: string;
|
||||
/** List of capability strings declared by the agent. */
|
||||
capabilities: string[];
|
||||
/** Canonical HTTPS endpoint for this agent on the SentryAgent.ai platform. */
|
||||
endpoint: string;
|
||||
/** ISO 8601 creation timestamp. */
|
||||
created_at: string;
|
||||
/** AGNTCY schema version this card conforms to. */
|
||||
agntcy_schema_version: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Internal DB row shapes
|
||||
// ============================================================================
|
||||
|
||||
/** Row returned when querying active agents for a tenant. */
|
||||
interface AgentRow {
|
||||
agent_id: string;
|
||||
owner: string;
|
||||
capabilities: string[];
|
||||
created_at: Date;
|
||||
did: string | null;
|
||||
}
|
||||
|
||||
/** Credential check result — one row per agent. */
|
||||
interface CredentialCheckRow {
|
||||
agent_id: string;
|
||||
expires_at: Date | null;
|
||||
is_expired: boolean;
|
||||
expires_soon: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
/** Redis TTL in seconds for cached compliance reports (5 minutes). */
|
||||
const CACHE_TTL_SECONDS = 300;
|
||||
|
||||
/** AGNTCY schema version supported by this implementation. */
|
||||
const AGNTCY_SCHEMA_VERSION = '1.0';
|
||||
|
||||
// ============================================================================
|
||||
// Service
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Service for generating AGNTCY compliance reports and exporting agent cards.
|
||||
*
|
||||
* Compliance report sections:
|
||||
* - agent-identity: Verifies all active agents have a valid DID and non-expired credential.
|
||||
* - audit-trail: Verifies the cryptographic integrity of the audit hash chain.
|
||||
*
|
||||
* Reports are cached in Redis under `compliance:report:<tenantId>` for 5 minutes.
|
||||
*/
|
||||
export class ComplianceService {
|
||||
/** @param pool - PostgreSQL connection pool. */
|
||||
/** @param redis - Connected Redis client for report caching. */
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
private readonly redis: RedisClientType,
|
||||
) {}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Public API
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generates an AGNTCY compliance report for the given tenant.
|
||||
*
|
||||
* The report is cached in Redis at `compliance:report:<tenantId>` for 5 minutes.
|
||||
* When a cached result is returned, `from_cache` is set to `true`.
|
||||
*
|
||||
* @param tenantId - The organization_id of the tenant.
|
||||
* @returns The compliance report (possibly from cache).
|
||||
*/
|
||||
async generateReport(tenantId: string): Promise<IComplianceReport> {
|
||||
const cacheKey = `compliance:report:${tenantId}`;
|
||||
|
||||
// Attempt cache read
|
||||
const cached = await this.redis.get(cacheKey);
|
||||
if (cached !== null) {
|
||||
const parsed = JSON.parse(cached) as IComplianceReport;
|
||||
parsed.from_cache = true;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
// Build all sections
|
||||
const sections: IComplianceSection[] = await Promise.all([
|
||||
this.buildAgentIdentitySection(tenantId),
|
||||
this.buildAuditTrailSection(tenantId),
|
||||
]);
|
||||
|
||||
const overall_status = this.rollUpStatus(sections);
|
||||
|
||||
const report: IComplianceReport = {
|
||||
generated_at: new Date().toISOString(),
|
||||
tenant_id: tenantId,
|
||||
agntcy_schema_version: AGNTCY_SCHEMA_VERSION,
|
||||
sections,
|
||||
overall_status,
|
||||
};
|
||||
|
||||
// Cache without from_cache field
|
||||
await this.redis.set(cacheKey, JSON.stringify(report), { EX: CACHE_TTL_SECONDS });
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports all active (non-decommissioned) agents for the given tenant as
|
||||
* AGNTCY-standard agent cards.
|
||||
*
|
||||
* @param tenantId - The organization_id of the tenant.
|
||||
* @returns Array of agent cards.
|
||||
*/
|
||||
async exportAgentCards(tenantId: string): Promise<IAgentCard[]> {
|
||||
const result = await this.pool.query<AgentRow>(
|
||||
`SELECT agent_id, owner, capabilities, created_at, did
|
||||
FROM agents
|
||||
WHERE organization_id = $1
|
||||
AND status != 'decommissioned'
|
||||
ORDER BY created_at ASC`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => this.toAgentCard(row));
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Section builders
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Builds the `agent-identity` compliance section.
|
||||
*
|
||||
* Checks each active agent for:
|
||||
* - Valid DID (did field must be non-null)
|
||||
* - Non-expired credential (expires_at > NOW())
|
||||
* - Credential expiring within 7 days triggers 'warn' status
|
||||
*
|
||||
* Status rules (in priority order):
|
||||
* - `fail`: any agent is missing a DID
|
||||
* - `warn`: any credential expires within 7 days
|
||||
* - `pass`: all checks pass
|
||||
*
|
||||
* @param tenantId - The organization_id to check.
|
||||
* @returns The agent-identity section.
|
||||
*/
|
||||
private async buildAgentIdentitySection(tenantId: string): Promise<IComplianceSection> {
|
||||
const agentResult = await this.pool.query<AgentRow>(
|
||||
`SELECT agent_id, owner, capabilities, created_at, did
|
||||
FROM agents
|
||||
WHERE organization_id = $1
|
||||
AND status != 'decommissioned'`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
const agents = agentResult.rows;
|
||||
|
||||
if (agents.length === 0) {
|
||||
return {
|
||||
name: 'agent-identity',
|
||||
status: 'pass',
|
||||
details: 'No active agents found for this tenant.',
|
||||
};
|
||||
}
|
||||
|
||||
// Check for missing DIDs
|
||||
const missingDid = agents.filter((a) => a.did === null || a.did === '');
|
||||
if (missingDid.length > 0) {
|
||||
return {
|
||||
name: 'agent-identity',
|
||||
status: 'fail',
|
||||
details: `${missingDid.length} agent(s) are missing a DID identifier: ${missingDid.map((a) => a.agent_id).join(', ')}.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check credentials for each agent
|
||||
const agentIds = agents.map((a) => a.agent_id);
|
||||
const credResult = await this.pool.query<CredentialCheckRow>(
|
||||
`SELECT
|
||||
c.client_id AS agent_id,
|
||||
c.expires_at,
|
||||
(c.expires_at IS NOT NULL AND c.expires_at <= NOW()) AS is_expired,
|
||||
(c.expires_at IS NOT NULL AND c.expires_at <= NOW() + INTERVAL '7 days' AND c.expires_at > NOW()) AS expires_soon
|
||||
FROM credentials c
|
||||
WHERE c.client_id = ANY($1::uuid[])
|
||||
AND c.status = 'active'`,
|
||||
[agentIds],
|
||||
);
|
||||
|
||||
const credMap = new Map<string, CredentialCheckRow>();
|
||||
for (const row of credResult.rows) {
|
||||
// Keep the most-recently-checked row (last active credential per agent)
|
||||
credMap.set(row.agent_id, row);
|
||||
}
|
||||
|
||||
const expiredAgents: string[] = [];
|
||||
const expiringSoonAgents: string[] = [];
|
||||
|
||||
for (const agent of agents) {
|
||||
const cred = credMap.get(agent.agent_id);
|
||||
if (cred) {
|
||||
if (cred.is_expired) {
|
||||
expiredAgents.push(agent.agent_id);
|
||||
} else if (cred.expires_soon) {
|
||||
expiringSoonAgents.push(agent.agent_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (expiredAgents.length > 0) {
|
||||
return {
|
||||
name: 'agent-identity',
|
||||
status: 'fail',
|
||||
details: `${expiredAgents.length} agent(s) have expired credentials: ${expiredAgents.join(', ')}.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (expiringSoonAgents.length > 0) {
|
||||
return {
|
||||
name: 'agent-identity',
|
||||
status: 'warn',
|
||||
details: `${expiringSoonAgents.length} agent(s) have credentials expiring within 7 days: ${expiringSoonAgents.join(', ')}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'agent-identity',
|
||||
status: 'pass',
|
||||
details: `All ${agents.length} active agent(s) have valid DIDs and non-expiring credentials.`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the `audit-trail` compliance section.
|
||||
*
|
||||
* Delegates to AuditVerificationService.verifyChain() with no date restrictions,
|
||||
* covering the full audit history.
|
||||
*
|
||||
* @param tenantId - Not used directly; AuditVerificationService checks global chain.
|
||||
* @returns The audit-trail section.
|
||||
*/
|
||||
private async buildAuditTrailSection(_tenantId: string): Promise<IComplianceSection> {
|
||||
const auditService = new AuditVerificationService(this.pool);
|
||||
const result = await auditService.verifyChain();
|
||||
|
||||
if (result.verified) {
|
||||
return {
|
||||
name: 'audit-trail',
|
||||
status: 'pass',
|
||||
details: `Audit chain intact. ${result.checkedCount} event(s) verified.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'audit-trail',
|
||||
status: 'fail',
|
||||
details: `Audit chain integrity failure detected at event ${result.brokenAtEventId ?? 'unknown'}. ${result.checkedCount} event(s) checked before break.`,
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Computes the rolled-up overall status from all sections.
|
||||
* Priority: fail > warn > pass.
|
||||
*
|
||||
* @param sections - The compliance sections to roll up.
|
||||
* @returns The worst status across all sections.
|
||||
*/
|
||||
private rollUpStatus(sections: IComplianceSection[]): ComplianceStatus {
|
||||
if (sections.some((s) => s.status === 'fail')) return 'fail';
|
||||
if (sections.some((s) => s.status === 'warn')) return 'warn';
|
||||
return 'pass';
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a raw DB agent row to an AGNTCY agent card.
|
||||
*
|
||||
* @param row - The agent row from the database.
|
||||
* @returns An AGNTCY-standard IAgentCard.
|
||||
*/
|
||||
private toAgentCard(row: AgentRow): IAgentCard {
|
||||
return {
|
||||
id: row.did ?? row.agent_id,
|
||||
name: row.owner,
|
||||
capabilities: row.capabilities,
|
||||
endpoint: `https://api.sentryagent.ai/agents/${row.agent_id}`,
|
||||
created_at: row.created_at.toISOString(),
|
||||
agntcy_schema_version: AGNTCY_SCHEMA_VERSION,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { VaultClient } from '../vault/VaultClient.js';
|
||||
import { IDTokenService } from './IDTokenService.js';
|
||||
import { EventPublisher } from './EventPublisher.js';
|
||||
import { EncryptionService } from './EncryptionService.js';
|
||||
import { AnalyticsService } from './AnalyticsService.js';
|
||||
import {
|
||||
ITokenPayload,
|
||||
ITokenResponse,
|
||||
@@ -55,6 +56,8 @@ export class OAuth2Service {
|
||||
* token.revoked events are published as webhooks and Kafka messages (fire-and-forget).
|
||||
* @param encryptionService - Optional EncryptionService. When provided, encrypted
|
||||
* `secret_hash` values are decrypted before bcrypt verification (SOC 2 CC6.1).
|
||||
* @param analyticsService - Optional AnalyticsService. When provided, a
|
||||
* `token_issued` event is recorded fire-and-forget on each successful issuance.
|
||||
*/
|
||||
constructor(
|
||||
private readonly tokenRepository: TokenRepository,
|
||||
@@ -67,6 +70,7 @@ export class OAuth2Service {
|
||||
private readonly idTokenService: IDTokenService | null = null,
|
||||
private readonly eventPublisher: EventPublisher | null = null,
|
||||
private readonly encryptionService: EncryptionService | null = null,
|
||||
private readonly analyticsService: AnalyticsService | null = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -230,6 +234,17 @@ export class OAuth2Service {
|
||||
// Instrument: count successful token issuances
|
||||
tokensIssuedTotal.inc({ scope });
|
||||
|
||||
// Analytics: record token issuance event (fire-and-forget — never blocks response)
|
||||
if (this.analyticsService !== null) {
|
||||
void this.analyticsService.recordEvent(
|
||||
agent.organizationId ?? 'org_system',
|
||||
'token_issued',
|
||||
).catch((err: unknown) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[OAuth2Service] analytics record failed', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Publish event (fire-and-forget)
|
||||
void this.eventPublisher?.publishEvent(
|
||||
agent.organizationId ?? 'org_system',
|
||||
|
||||
261
src/services/TierService.ts
Normal file
261
src/services/TierService.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Tier Service for SentryAgent.ai AgentIdP.
|
||||
* Single authority for all tier-related business logic:
|
||||
* - Fetching current tier and usage status
|
||||
* - Initiating Stripe checkout for tier upgrades
|
||||
* - Applying a confirmed tier upgrade to the organizations table
|
||||
*/
|
||||
|
||||
import { Pool } from 'pg';
|
||||
import Stripe from 'stripe';
|
||||
import type { RedisClientType } from 'redis';
|
||||
import { TIER_CONFIG, TierName, TIER_RANK, isTierName } from '../config/tiers.js';
|
||||
import { ValidationError, TierLimitError } from '../utils/errors.js';
|
||||
|
||||
/** Redis key prefixes for daily counters. */
|
||||
const CALLS_KEY_PREFIX = 'rate:tier:calls:';
|
||||
const TOKENS_KEY_PREFIX = 'rate:tier:tokens:';
|
||||
|
||||
/**
|
||||
* Current tier status snapshot returned by getStatus().
|
||||
*/
|
||||
export interface ITierStatus {
|
||||
/** The tenant's current tier name. */
|
||||
tier: TierName;
|
||||
/** Per-tier limits from TIER_CONFIG. */
|
||||
limits: {
|
||||
maxAgents: number;
|
||||
maxCallsPerDay: number;
|
||||
maxTokensPerDay: number;
|
||||
};
|
||||
/** Live usage counters for the current UTC day. */
|
||||
usage: {
|
||||
/** Number of API calls made today (from Redis). */
|
||||
callsToday: number;
|
||||
/** Number of tokens issued today (from Redis). */
|
||||
tokensToday: number;
|
||||
/** Number of non-decommissioned agents for this org. */
|
||||
agentCount: number;
|
||||
};
|
||||
/** ISO 8601 timestamp of the next UTC midnight (daily limit reset time). */
|
||||
resetAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of initiateUpgrade() — contains the Stripe Checkout URL.
|
||||
*/
|
||||
export interface IUpgradeInitiation {
|
||||
/** URL the tenant should be redirected to for payment. */
|
||||
checkoutUrl: string;
|
||||
}
|
||||
|
||||
/** DB row shape for organization tier queries. */
|
||||
interface IOrgTierRow {
|
||||
tier: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for tenant tier management.
|
||||
* Owns all tier logic — controllers and middleware delegate to this service.
|
||||
*/
|
||||
export class TierService {
|
||||
/**
|
||||
* @param pool - PostgreSQL connection pool.
|
||||
* @param redis - Redis client for usage counter access.
|
||||
* @param stripe - Configured Stripe client instance.
|
||||
*/
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
private readonly redis: RedisClientType,
|
||||
private readonly stripe: Stripe,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Public API
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the current tier, limits, usage, and daily reset time for an org.
|
||||
*
|
||||
* Usage counters are read directly from Redis (live counts).
|
||||
* Agent count is read from the database.
|
||||
* Falls back gracefully if Redis is unavailable (returns 0 for Redis-backed counters).
|
||||
*
|
||||
* @param orgId - The organization UUID.
|
||||
* @returns ITierStatus snapshot.
|
||||
*/
|
||||
async getStatus(orgId: string): Promise<ITierStatus> {
|
||||
const tier = await this.fetchTier(orgId);
|
||||
const limits = TIER_CONFIG[tier];
|
||||
|
||||
const [callsToday, tokensToday, agentCount] = await Promise.all([
|
||||
this.readRedisCounter(CALLS_KEY_PREFIX + orgId),
|
||||
this.readRedisCounter(TOKENS_KEY_PREFIX + orgId),
|
||||
this.fetchAgentCount(orgId),
|
||||
]);
|
||||
|
||||
const resetAt = this.nextUtcMidnight().toISOString();
|
||||
|
||||
return {
|
||||
tier,
|
||||
limits: {
|
||||
maxAgents: limits.maxAgents,
|
||||
maxCallsPerDay: limits.maxCallsPerDay,
|
||||
maxTokensPerDay: limits.maxTokensPerDay,
|
||||
},
|
||||
usage: { callsToday, tokensToday, agentCount },
|
||||
resetAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the target tier is a valid upgrade and creates a Stripe Checkout
|
||||
* Session for the new tier's price. Returns the checkout URL.
|
||||
*
|
||||
* Metadata on the session includes `{ orgId, targetTier }` so the webhook handler
|
||||
* can apply the upgrade after payment succeeds.
|
||||
*
|
||||
* @param orgId - The organization UUID.
|
||||
* @param targetTier - The desired new tier.
|
||||
* @returns IUpgradeInitiation with the Stripe checkout URL.
|
||||
* @throws ValidationError if targetTier is not higher than the current tier.
|
||||
* @throws Error if Stripe does not return a session URL.
|
||||
*/
|
||||
async initiateUpgrade(orgId: string, targetTier: TierName): Promise<IUpgradeInitiation> {
|
||||
const currentTier = await this.fetchTier(orgId);
|
||||
|
||||
if (TIER_RANK[targetTier] <= TIER_RANK[currentTier]) {
|
||||
throw new ValidationError(
|
||||
`Cannot downgrade or remain on the same tier. Current tier: ${currentTier}. Downgrades require contacting support.`,
|
||||
{ currentTier, targetTier },
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve the Stripe price ID for the target tier.
|
||||
// Each tier maps to a dedicated price ID env var: STRIPE_PRICE_ID_PRO, STRIPE_PRICE_ID_ENTERPRISE.
|
||||
const priceIdEnvKey = `STRIPE_PRICE_ID_${targetTier.toUpperCase()}`;
|
||||
const priceId = process.env[priceIdEnvKey] ?? process.env['STRIPE_PRICE_ID'];
|
||||
|
||||
const session = await this.stripe.checkout.sessions.create({
|
||||
mode: 'subscription',
|
||||
client_reference_id: orgId,
|
||||
metadata: { orgId, targetTier },
|
||||
line_items: priceId ? [{ price: priceId, quantity: 1 }] : undefined,
|
||||
success_url:
|
||||
process.env['STRIPE_SUCCESS_URL'] ??
|
||||
`${process.env['APP_BASE_URL'] ?? 'http://localhost:3000'}/dashboard?billing=success`,
|
||||
cancel_url:
|
||||
process.env['STRIPE_CANCEL_URL'] ??
|
||||
`${process.env['APP_BASE_URL'] ?? 'http://localhost:3000'}/dashboard?billing=cancel`,
|
||||
});
|
||||
|
||||
if (!session.url) {
|
||||
throw new Error('Stripe did not return a checkout session URL.');
|
||||
}
|
||||
|
||||
return { checkoutUrl: session.url };
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a confirmed tier upgrade to the organizations table.
|
||||
* Sets both `tier` and `tier_updated_at`.
|
||||
* Called by the Stripe webhook handler after `checkout.session.completed`.
|
||||
*
|
||||
* @param orgId - The organization UUID.
|
||||
* @param tier - The new tier to apply.
|
||||
* @returns Promise that resolves when the update is persisted.
|
||||
*/
|
||||
async applyUpgrade(orgId: string, tier: TierName): Promise<void> {
|
||||
await this.pool.query(
|
||||
`UPDATE organizations
|
||||
SET tier = $1, tier_updated_at = NOW()
|
||||
WHERE organization_id = $2`,
|
||||
[tier, orgId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the current tier for an org from the database.
|
||||
* Returns 'free' as the safe default when no row is found.
|
||||
*
|
||||
* @param orgId - The organization UUID.
|
||||
* @returns The current TierName.
|
||||
*/
|
||||
async fetchTier(orgId: string): Promise<TierName> {
|
||||
const result = await this.pool.query<IOrgTierRow>(
|
||||
`SELECT tier FROM organizations WHERE organization_id = $1 LIMIT 1`,
|
||||
[orgId],
|
||||
);
|
||||
if (result.rows.length === 0) return 'free';
|
||||
const raw = result.rows[0].tier;
|
||||
return isTierName(raw) ? raw : 'free';
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Internal helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reads an integer counter from Redis. Returns 0 on error or missing key.
|
||||
*
|
||||
* @param key - The full Redis key.
|
||||
* @returns The counter value, or 0 when unavailable.
|
||||
*/
|
||||
private async readRedisCounter(key: string): Promise<number> {
|
||||
try {
|
||||
const value = await this.redis.get(key);
|
||||
if (value === null) return 0;
|
||||
const parsed = parseInt(value, 10);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts non-decommissioned agents for an org.
|
||||
*
|
||||
* @param orgId - The organization UUID.
|
||||
* @returns Agent count.
|
||||
*/
|
||||
private async fetchAgentCount(orgId: string): Promise<number> {
|
||||
const result = await this.pool.query<{ count: string }>(
|
||||
`SELECT COUNT(*) AS count
|
||||
FROM agents
|
||||
WHERE organization_id = $1 AND status != 'decommissioned'`,
|
||||
[orgId],
|
||||
);
|
||||
return parseInt(result.rows[0]?.count ?? '0', 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Date object representing the next UTC midnight.
|
||||
*
|
||||
* @returns Date set to 00:00:00.000 UTC of the next calendar day.
|
||||
*/
|
||||
private nextUtcMidnight(): Date {
|
||||
const d = new Date();
|
||||
d.setUTCHours(24, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces the per-org agent count limit for the given tier.
|
||||
* Throws TierLimitError when the current count is at or over the allowed maximum.
|
||||
* Used by AgentService before creating a new agent.
|
||||
*
|
||||
* @param orgId - The organization UUID.
|
||||
* @param tier - The organization's current tier.
|
||||
* @throws TierLimitError when the limit is reached.
|
||||
*/
|
||||
async enforceAgentLimit(orgId: string, tier: TierName): Promise<void> {
|
||||
const limit = TIER_CONFIG[tier].maxAgents;
|
||||
// Infinity means enterprise — no enforcement
|
||||
if (!isFinite(limit)) return;
|
||||
|
||||
const count = await this.fetchAgentCount(orgId);
|
||||
if (count >= limit) {
|
||||
throw new TierLimitError('agent', limit, { orgId, tier, current: count });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,3 +199,20 @@ export class AlreadyMemberError extends SentryAgentError {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 429 — Tenant has exceeded a tier-based resource limit (agents, API calls, or tokens). */
|
||||
export class TierLimitError extends SentryAgentError {
|
||||
/**
|
||||
* @param limitType - Human-readable name of the limit that was exceeded (e.g. 'agent', 'API call').
|
||||
* @param limit - The numeric limit that was reached.
|
||||
* @param details - Optional extra structured detail.
|
||||
*/
|
||||
constructor(limitType: string, limit: number, details?: Record<string, unknown>) {
|
||||
super(
|
||||
`You have reached your ${limitType} limit of ${limit}. Upgrade your plan to increase this limit.`,
|
||||
'tier_limit_exceeded',
|
||||
429,
|
||||
{ limitType, limit, ...details },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user