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:
SentryAgent.ai Developer
2026-04-04 02:20:09 +00:00
parent 0fad328329
commit eea885db04
34 changed files with 4262 additions and 25 deletions

View File

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

View 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);
}
};
}

View File

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

View 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);
}
};
}

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

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

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

View File

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

View File

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

View 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 (190).
* @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),
}));
}
}

View File

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

View 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,
};
}
}

View File

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

View File

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