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

@@ -13,7 +13,8 @@
"db:migrate": "ts-node scripts/migrate.ts",
"lint": "eslint src --ext .ts",
"format": "prettier --write src/**/*.ts",
"load-test": "k6 run tests/load/agent-registration.js && k6 run tests/load/token-issuance.js && k6 run tests/load/credential-rotation.js"
"load-test": "k6 run tests/load/agent-registration.js && k6 run tests/load/token-issuance.js && k6 run tests/load/credential-rotation.js",
"test:agntcy-conformance": "jest --config tests/agntcy-conformance/jest.config.cjs"
},
"dependencies": {
"@open-policy-agent/opa-wasm": "^1.10.0",

View File

@@ -0,0 +1,351 @@
'use client';
/**
* AnalyticsPage — Tenant analytics dashboard for the SentryAgent portal.
*
* Displays:
* - Token issuance trend (last 30 days) via TokenTrendChart (lazy-loaded)
* - Agent activity by day-of-week via AgentHeatmap (lazy-loaded)
* - Per-agent usage table for the current billing period
*
* All chart components are code-split via `next/dynamic` so that recharts
* is excluded from the main bundle.
*
* Protected route: redirects to `/login` when no JWT is present (via useAuth).
*
* @module app/analytics/page
*/
import React, { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { useAuth } from '@/hooks/useAuth';
import type { TokenTrendDataPoint } from '@/components/charts/TokenTrendChart';
import type { AgentActivityBucket } from '@/components/charts/AgentHeatmap';
/* -------------------------------------------------------------------------
* Lazy-loaded chart components (recharts stays out of the main bundle)
* ---------------------------------------------------------------------- */
const TokenTrendChart = dynamic(
() => import('@/components/charts/TokenTrendChart'),
{
ssr: false,
loading: () => (
<ChartSkeleton label="Loading token trend chart…" height={300} />
),
},
);
const AgentHeatmap = dynamic(
() => import('@/components/charts/AgentHeatmap'),
{
ssr: false,
loading: () => (
<ChartSkeleton label="Loading activity chart…" height={300} />
),
},
);
/* -------------------------------------------------------------------------
* API response types
* ---------------------------------------------------------------------- */
/** A single entry from `GET /api/analytics/agents`. */
interface AgentUsageSummary {
agent_id: string;
name: string;
token_count: number;
}
/** Root shape of `GET /api/analytics/agents/activity`. */
type AgentActivityResponse = AgentActivityBucket[];
/** Root shape of `GET /api/analytics/tokens`. */
type TokenTrendResponse = TokenTrendDataPoint[];
/** Root shape of `GET /api/analytics/agents`. */
type AgentUsageResponse = AgentUsageSummary[];
/* -------------------------------------------------------------------------
* Page state
* ---------------------------------------------------------------------- */
/** Loading / error / data state for a single async fetch. */
interface FetchState<T> {
data: T | null;
loading: boolean;
error: string | null;
}
function initFetchState<T>(): FetchState<T> {
return { data: null, loading: true, error: null };
}
/* -------------------------------------------------------------------------
* Helpers
* ---------------------------------------------------------------------- */
/**
* Fetches a JSON endpoint with an Authorization bearer token.
*
* @param url - Absolute URL to fetch
* @param token - JWT bearer token
* @returns Parsed JSON of type T
* @throws Error with a descriptive message on non-2xx or network failure
*/
async function fetchWithAuth<T>(url: string, token: string): Promise<T> {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { message?: string };
throw new Error(body.message ?? `Request failed: ${res.status}`);
}
return res.json() as Promise<T>;
}
/* -------------------------------------------------------------------------
* Sub-components
* ---------------------------------------------------------------------- */
/** Props for ChartSkeleton. */
interface ChartSkeletonProps {
label: string;
height: number;
}
/**
* Placeholder shown while a dynamic chart chunk is loading.
*
* @param props - ChartSkeletonProps
* @returns JSX element
*/
function ChartSkeleton({ label, height }: ChartSkeletonProps): React.ReactElement {
return (
<div
className="flex animate-pulse items-center justify-center rounded-xl bg-slate-100 text-sm text-slate-400"
style={{ height }}
aria-label={label}
>
{label}
</div>
);
}
/** Props for SectionCard. */
interface SectionCardProps {
title: string;
children: React.ReactNode;
}
/**
* Simple card wrapper for dashboard sections.
*
* @param props - SectionCardProps
* @returns JSX element
*/
function SectionCard({ title, children }: SectionCardProps): React.ReactElement {
return (
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-slate-900">{title}</h2>
{children}
</div>
);
}
/** Props for ErrorBanner. */
interface ErrorBannerProps {
message: string;
}
/**
* Inline error banner for failed data fetches.
*
* @param props - ErrorBannerProps
* @returns JSX element
*/
function ErrorBanner({ message }: ErrorBannerProps): React.ReactElement {
return (
<p className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
{message}
</p>
);
}
/* -------------------------------------------------------------------------
* Page component
* ---------------------------------------------------------------------- */
/**
* Renders the analytics dashboard page.
*
* Checks authentication via `useAuth` (redirects to /login if no token).
* Fetches analytics data from the AgentIdP API using the stored JWT.
*
* @returns JSX element
*/
export default function AnalyticsPage(): React.ReactElement {
const { token, loading: authLoading } = useAuth(true);
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
const [tokenTrend, setTokenTrend] =
useState<FetchState<TokenTrendResponse>>(initFetchState);
const [agentActivity, setAgentActivity] =
useState<FetchState<AgentActivityResponse>>(initFetchState);
const [agentUsage, setAgentUsage] =
useState<FetchState<AgentUsageResponse>>(initFetchState);
useEffect(() => {
// Wait until auth state is resolved and token is available
if (authLoading || token === null) return;
void fetchWithAuth<TokenTrendResponse>(
`${apiUrl}/api/analytics/tokens?days=30`,
token,
)
.then((data) => setTokenTrend({ data, loading: false, error: null }))
.catch((err: unknown) =>
setTokenTrend({
data: null,
loading: false,
error: err instanceof Error ? err.message : 'Failed to load token trend',
}),
);
void fetchWithAuth<AgentActivityResponse>(
`${apiUrl}/api/analytics/agents/activity`,
token,
)
.then((data) => setAgentActivity({ data, loading: false, error: null }))
.catch((err: unknown) =>
setAgentActivity({
data: null,
loading: false,
error: err instanceof Error ? err.message : 'Failed to load agent activity',
}),
);
void fetchWithAuth<AgentUsageResponse>(
`${apiUrl}/api/analytics/agents`,
token,
)
.then((data) => setAgentUsage({ data, loading: false, error: null }))
.catch((err: unknown) =>
setAgentUsage({
data: null,
loading: false,
error: err instanceof Error ? err.message : 'Failed to load agent usage',
}),
);
}, [authLoading, token, apiUrl]);
// While auth state is being resolved, show a full-page loading state
if (authLoading) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<p className="text-slate-500">Loading</p>
</div>
);
}
// token === null means useAuth is redirecting to /login; render nothing
if (token === null) {
return <></>;
}
return (
<div className="px-6 py-16">
<div className="mx-auto max-w-6xl">
{/* Page header */}
<div className="mb-10">
<h1 className="text-4xl font-extrabold text-slate-900">Analytics</h1>
<p className="mt-2 text-slate-600">
Token issuance and agent activity for the last 30 days
</p>
</div>
<div className="grid gap-8">
{/* Token Trend */}
<SectionCard title="Token Issuance Trend (Last 30 Days)">
{tokenTrend.error !== null && (
<ErrorBanner message={tokenTrend.error} />
)}
{tokenTrend.loading && tokenTrend.error === null && (
<ChartSkeleton label="Loading token trend chart…" height={300} />
)}
{tokenTrend.data !== null && (
<TokenTrendChart data={tokenTrend.data} />
)}
</SectionCard>
{/* Agent Activity Heatmap */}
<SectionCard title="Agent Activity by Day of Week (Last 30 Days)">
{agentActivity.error !== null && (
<ErrorBanner message={agentActivity.error} />
)}
{agentActivity.loading && agentActivity.error === null && (
<ChartSkeleton label="Loading activity chart…" height={300} />
)}
{agentActivity.data !== null && (
<AgentHeatmap data={agentActivity.data} />
)}
</SectionCard>
{/* Per-Agent Usage Table */}
<SectionCard title="Per-Agent Usage (Current Month)">
{agentUsage.error !== null && (
<ErrorBanner message={agentUsage.error} />
)}
{agentUsage.loading && agentUsage.error === null && (
<div className="animate-pulse space-y-2">
{[1, 2, 3].map((n) => (
<div key={n} className="h-10 rounded bg-slate-100" />
))}
</div>
)}
{agentUsage.data !== null && agentUsage.data.length === 0 && (
<p className="text-sm text-slate-500">
No agents have issued tokens this month.
</p>
)}
{agentUsage.data !== null && agentUsage.data.length > 0 && (
<div className="overflow-hidden rounded-xl border border-slate-200">
<table className="w-full text-sm">
<thead>
<tr className="bg-slate-50">
<th className="px-4 py-3 text-left font-semibold text-slate-700">
Agent
</th>
<th className="px-4 py-3 text-right font-semibold text-slate-700">
Tokens Issued
</th>
</tr>
</thead>
<tbody>
{agentUsage.data.map(({ agent_id, name, token_count }, i) => (
<tr
key={agent_id}
className={i % 2 === 0 ? 'bg-white' : 'bg-slate-50'}
>
<td className="px-4 py-3 text-slate-700">
<span className="font-medium">{name}</span>
<span className="ml-2 font-mono text-xs text-slate-400">
{agent_id}
</span>
</td>
<td className="px-4 py-3 text-right font-medium tabular-nums text-slate-900">
{token_count.toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</SectionCard>
</div>
</div>
</div>
);
}

137
portal/app/login/page.tsx Normal file
View File

@@ -0,0 +1,137 @@
'use client';
/**
* LoginPage — Tenant admin sign-in page for the SentryAgent developer portal.
*
* Posts credentials to `POST /api/tenants/login` on the AgentIdP backend and
* stores the returned JWT in localStorage so that protected pages (e.g.
* /analytics) can read it via the `useAuth` hook.
*
* @module app/login/page
*/
import React, { useState, type FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import { AUTH_TOKEN_KEY } from '@/hooks/useAuth';
/** Shape of the successful login response from the AgentIdP API. */
interface LoginResponse {
access_token: string;
}
/**
* Renders the portal login form and handles credential submission.
*
* @returns JSX element
*/
export default function LoginPage(): React.ReactElement {
const router = useRouter();
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
/**
* Submits the login form and stores the JWT on success.
*
* @param e - The form submission event
*/
async function handleSubmit(e: FormEvent<HTMLFormElement>): Promise<void> {
e.preventDefault();
setError(null);
setSubmitting(true);
try {
const res = await fetch(`${apiUrl}/api/tenants/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { message?: string };
setError(body.message ?? 'Invalid credentials. Please try again.');
return;
}
const data = (await res.json()) as LoginResponse;
localStorage.setItem(AUTH_TOKEN_KEY, data.access_token);
router.replace('/analytics');
} catch {
setError('Network error. Please check your connection and try again.');
} finally {
setSubmitting(false);
}
}
return (
<div className="flex min-h-[70vh] items-center justify-center px-6 py-16">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<h1 className="text-3xl font-extrabold text-slate-900">Sign in</h1>
<p className="mt-2 text-slate-600">
Access your SentryAgent tenant dashboard
</p>
</div>
<form
onSubmit={(e) => void handleSubmit(e)}
className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm"
>
<div className="mb-4">
<label
htmlFor="email"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Email address
</label>
<input
id="email"
type="email"
required
autoComplete="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
placeholder="admin@example.com"
/>
</div>
<div className="mb-6">
<label
htmlFor="password"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Password
</label>
<input
id="password"
type="password"
required
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
/>
</div>
{error !== null && (
<p className="mb-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</p>
)}
<button
type="submit"
disabled={submitting}
className="w-full rounded-lg bg-brand-600 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-brand-700 disabled:opacity-60"
>
{submitting ? 'Signing in…' : 'Sign in'}
</button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,432 @@
'use client';
/**
* TierPage — Tenant tier & billing dashboard for the SentryAgent portal.
*
* Displays:
* - Current tier name (styled badge)
* - Daily limits table: agents, API calls, token issuances
* - Current usage vs. limit for each metric with a visual progress bar
* - Time until daily reset
* - For free/pro tiers: an "Upgrade" button that calls POST /api/v1/tiers/upgrade
* and redirects to the returned Stripe checkoutUrl
* - For enterprise tier: an "Enterprise — Unlimited" label with no upgrade button
*
* Protected route: redirects to /login when no JWT is present (via useAuth).
*
* @module app/settings/tier/page
*/
import React, { useEffect, useState } from 'react';
import { useAuth } from '@/hooks/useAuth';
/* -------------------------------------------------------------------------
* Constants
* ---------------------------------------------------------------------- */
/** The ordered sequence of upgradeable tiers. */
const UPGRADE_PATH: Record<string, string> = {
free: 'pro',
pro: 'enterprise',
};
/** Human-readable tier labels. */
const TIER_LABELS: Record<string, string> = {
free: 'Free',
pro: 'Pro',
enterprise: 'Enterprise',
};
/** Tailwind badge colour classes per tier. */
const TIER_BADGE_CLASSES: Record<string, string> = {
free: 'bg-slate-100 text-slate-700',
pro: 'bg-brand-100 text-brand-700',
enterprise: 'bg-emerald-100 text-emerald-700',
};
/* -------------------------------------------------------------------------
* API response types
* ---------------------------------------------------------------------- */
/** Per-metric limit and usage returned by GET /api/v1/tiers/status. */
interface TierMetric {
/** Hard daily ceiling, or null when unlimited (enterprise). */
limit: number | null;
/** Current usage count for today. */
used: number;
}
/** Shape returned by GET /api/v1/tiers/status. */
interface TierStatus {
/** Canonical tier identifier: "free" | "pro" | "enterprise". */
tier: string;
/** Daily metrics. */
limits: {
agents: TierMetric;
api_calls: TierMetric;
token_issuances: TierMetric;
};
/** ISO-8601 timestamp for when the daily counters reset (UTC midnight). */
reset_at: string;
}
/** Shape returned by POST /api/v1/tiers/upgrade. */
interface UpgradeResponse {
/** Stripe Checkout URL to redirect the user to. */
checkoutUrl: string;
}
/* -------------------------------------------------------------------------
* Helpers
* ---------------------------------------------------------------------- */
/**
* Fetches a JSON endpoint with an Authorization bearer token.
*
* @param url - Absolute URL to fetch
* @param token - JWT bearer token
* @param options - Optional RequestInit overrides
* @returns Parsed JSON of type T
* @throws Error with a descriptive message on non-2xx or network failure
*/
async function fetchWithAuth<T>(
url: string,
token: string,
options: RequestInit = {},
): Promise<T> {
const res = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...(options.headers ?? {}),
},
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { message?: string };
throw new Error(body.message ?? `Request failed: ${res.status}`);
}
return res.json() as Promise<T>;
}
/**
* Returns the number of whole minutes (rounded up) until the given ISO-8601
* reset timestamp.
*
* @param resetAt - ISO-8601 date string
* @returns Human-readable time-until string, e.g. "3 h 42 min"
*/
function formatTimeUntilReset(resetAt: string): string {
const msUntil = new Date(resetAt).getTime() - Date.now();
if (msUntil <= 0) return 'resetting now';
const totalMinutes = Math.ceil(msUntil / 60_000);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours === 0) return `${minutes} min`;
return `${hours} h ${minutes} min`;
}
/* -------------------------------------------------------------------------
* Sub-components
* ---------------------------------------------------------------------- */
/** Props for UsageRow. */
interface UsageRowProps {
/** Display label, e.g. "API Calls". */
label: string;
/** Current usage count. */
used: number;
/** Hard ceiling, or null for unlimited. */
limit: number | null;
}
/**
* Renders a single row in the usage table with a visual progress bar.
*
* @param props - UsageRowProps
* @returns JSX element
*/
function UsageRow({ label, used, limit }: UsageRowProps): React.ReactElement {
const isUnlimited = limit === null;
const pct = isUnlimited ? 0 : Math.min(100, Math.round((used / limit) * 100));
const barColour =
pct >= 90 ? 'bg-red-500' : pct >= 70 ? 'bg-amber-400' : 'bg-brand-500';
return (
<tr>
<td className="px-4 py-3 text-sm font-medium text-slate-700">{label}</td>
<td className="px-4 py-3 text-right text-sm tabular-nums text-slate-900">
{used.toLocaleString()}
</td>
<td className="px-4 py-3 text-right text-sm tabular-nums text-slate-500">
{isUnlimited ? '∞' : limit.toLocaleString()}
</td>
<td className="px-4 py-3">
{isUnlimited ? (
<span className="text-xs font-medium text-emerald-600">Unlimited</span>
) : (
<div className="flex items-center gap-2">
<div className="h-2 w-24 overflow-hidden rounded-full bg-slate-100">
<div
className={`h-full rounded-full transition-all ${barColour}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-slate-400">{pct}%</span>
</div>
)}
</td>
</tr>
);
}
/** Props for TierBadge. */
interface TierBadgeProps {
tier: string;
}
/**
* Renders a styled pill badge for the given tier name.
*
* @param props - TierBadgeProps
* @returns JSX element
*/
function TierBadge({ tier }: TierBadgeProps): React.ReactElement {
const colourClass =
TIER_BADGE_CLASSES[tier] ?? 'bg-slate-100 text-slate-700';
const label = TIER_LABELS[tier] ?? tier;
return (
<span
className={`inline-block rounded-full px-3 py-1 text-sm font-semibold ${colourClass}`}
>
{label}
</span>
);
}
/** Props for ErrorBanner. */
interface ErrorBannerProps {
message: string;
}
/**
* Inline error banner for failed data fetches or actions.
*
* @param props - ErrorBannerProps
* @returns JSX element
*/
function ErrorBanner({ message }: ErrorBannerProps): React.ReactElement {
return (
<p className="rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700">
{message}
</p>
);
}
/* -------------------------------------------------------------------------
* Page component
* ---------------------------------------------------------------------- */
/**
* Renders the Tier & Billing settings page.
*
* Checks authentication via `useAuth` (redirects to /login if no token).
* Fetches tier status from the AgentIdP API using the stored JWT.
*
* @returns JSX element
*/
export default function TierPage(): React.ReactElement {
const { token, loading: authLoading } = useAuth(true);
const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000';
const [status, setStatus] = useState<TierStatus | null>(null);
const [fetchLoading, setFetchLoading] = useState(true);
const [fetchError, setFetchError] = useState<string | null>(null);
const [upgrading, setUpgrading] = useState(false);
const [upgradeError, setUpgradeError] = useState<string | null>(null);
useEffect(() => {
if (authLoading || token === null) return;
void fetchWithAuth<TierStatus>(`${apiUrl}/api/v1/tiers/status`, token)
.then((data) => {
setStatus(data);
setFetchLoading(false);
})
.catch((err: unknown) => {
setFetchError(
err instanceof Error ? err.message : 'Failed to load tier status',
);
setFetchLoading(false);
});
}, [authLoading, token, apiUrl]);
/**
* Initiates a tier upgrade by calling POST /api/v1/tiers/upgrade and
* redirecting the browser to the returned Stripe Checkout URL.
*/
async function handleUpgrade(): Promise<void> {
if (token === null || status === null) return;
const targetTier = UPGRADE_PATH[status.tier];
if (targetTier === undefined) return;
setUpgrading(true);
setUpgradeError(null);
try {
const { checkoutUrl } = await fetchWithAuth<UpgradeResponse>(
`${apiUrl}/api/v1/tiers/upgrade`,
token,
{
method: 'POST',
body: JSON.stringify({ target_tier: targetTier }),
},
);
window.location.href = checkoutUrl;
} catch (err: unknown) {
setUpgradeError(
err instanceof Error ? err.message : 'Upgrade failed — please try again',
);
setUpgrading(false);
}
}
/* ------------------------------------------------------------------
* Render: auth loading
* ---------------------------------------------------------------- */
if (authLoading) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<p className="text-slate-500">Loading</p>
</div>
);
}
// token === null means useAuth is redirecting to /login; render nothing
if (token === null) {
return <></>;
}
/* ------------------------------------------------------------------
* Render: data loading / error
* ---------------------------------------------------------------- */
return (
<div className="px-6 py-16">
<div className="mx-auto max-w-3xl">
{/* Page header */}
<div className="mb-10">
<h1 className="text-4xl font-extrabold text-slate-900">
Tier &amp; Billing
</h1>
<p className="mt-2 text-slate-600">
Your current plan, daily limits, and usage at a glance
</p>
</div>
{fetchError !== null && <ErrorBanner message={fetchError} />}
{fetchLoading && fetchError === null && (
<div className="animate-pulse space-y-4">
<div className="h-8 w-32 rounded-full bg-slate-100" />
<div className="h-48 rounded-2xl bg-slate-100" />
</div>
)}
{status !== null && (
<div className="space-y-6">
{/* Current tier card */}
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="flex items-center justify-between">
<div>
<p className="mb-1 text-sm font-medium text-slate-500">
Current Plan
</p>
<TierBadge tier={status.tier} />
</div>
{/* Enterprise: show label; free/pro: show upgrade button */}
{status.tier === 'enterprise' ? (
<span className="text-sm font-semibold text-emerald-600">
Enterprise Unlimited
</span>
) : (
<div className="flex flex-col items-end gap-2">
{upgradeError !== null && (
<ErrorBanner message={upgradeError} />
)}
<button
type="button"
onClick={() => { void handleUpgrade(); }}
disabled={upgrading}
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{upgrading
? 'Redirecting…'
: `Upgrade to ${TIER_LABELS[UPGRADE_PATH[status.tier] ?? ''] ?? 'Next Tier'}`}
</button>
</div>
)}
</div>
</div>
{/* Usage table card */}
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<h2 className="mb-4 text-lg font-semibold text-slate-900">
Daily Usage
</h2>
<div className="overflow-hidden rounded-xl border border-slate-200">
<table className="w-full text-sm">
<thead>
<tr className="bg-slate-50">
<th className="px-4 py-3 text-left font-semibold text-slate-700">
Metric
</th>
<th className="px-4 py-3 text-right font-semibold text-slate-700">
Used
</th>
<th className="px-4 py-3 text-right font-semibold text-slate-700">
Limit
</th>
<th className="px-4 py-3 text-left font-semibold text-slate-700">
Usage
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
<UsageRow
label="Agents"
used={status.limits.agents.used}
limit={status.limits.agents.limit}
/>
<UsageRow
label="API Calls"
used={status.limits.api_calls.used}
limit={status.limits.api_calls.limit}
/>
<UsageRow
label="Token Issuances"
used={status.limits.token_issuances.used}
limit={status.limits.token_issuances.limit}
/>
</tbody>
</table>
</div>
<p className="mt-3 text-xs text-slate-400">
Counters reset in{' '}
<span className="font-medium text-slate-600">
{formatTimeUntilReset(status.reset_at)}
</span>{' '}
(UTC midnight)
</p>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -15,6 +15,7 @@ const links: NavLink[] = [
{ href: '/get-started', label: 'Get Started' },
{ href: '/sdks', label: 'SDKs' },
{ href: '/pricing', label: 'Pricing' },
{ href: '/analytics', label: 'Analytics' },
];
export function Nav(): React.ReactElement {

View File

@@ -0,0 +1,133 @@
'use client';
/**
* AgentHeatmap — Recharts BarChart showing agent activity grouped by day-of-week.
*
* The API returns daily aggregates (no hour granularity), so this component
* aggregates event counts per day-of-week across all agents and renders a
* grouped bar chart (one bar per day, MonSun).
*
* This component is designed to be lazy-loaded via `next/dynamic`. Do NOT
* import it directly from a page; use dynamic(() => import('./AgentHeatmap'))
* so that recharts stays out of the main bundle.
*
* @module components/charts/AgentHeatmap
*/
import React, { useMemo } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
/** A single activity bucket from the API response. */
export interface AgentActivityBucket {
/** The agent's unique identifier. */
agent_id: string;
/** Day-of-week as an integer (0 = Sunday … 6 = Saturday). */
dow: number;
/** Total event count for this agent on this day-of-week. */
count: number;
}
/** Props for the AgentHeatmap component. */
export interface AgentHeatmapProps {
/**
* Array of per-agent, per-day-of-week activity buckets from
* `GET /api/analytics/agents/activity`.
*/
data: AgentActivityBucket[];
}
/** Display labels for days of the week, ordered MonSun (dow 10). */
const DOW_LABELS: Record<number, string> = {
0: 'Sun',
1: 'Mon',
2: 'Tue',
3: 'Wed',
4: 'Thu',
5: 'Fri',
6: 'Sat',
};
/** Ordered day-of-week values for Mon → Sun display. */
const DOW_ORDER = [1, 2, 3, 4, 5, 6, 0];
/** Aggregated count per day-of-week for chart rendering. */
interface DowAggregate {
day: string;
count: number;
}
/**
* Aggregates raw per-agent activity buckets into per-day-of-week totals
* suitable for the bar chart.
*
* @param data - Raw activity buckets from the API
* @returns Array of { day, count } sorted Mon → Sun
*/
function aggregateByDow(data: AgentActivityBucket[]): DowAggregate[] {
const totals: Record<number, number> = {};
for (const dow of DOW_ORDER) {
totals[dow] = 0;
}
for (const bucket of data) {
if (bucket.dow in totals) {
totals[bucket.dow] += bucket.count;
}
}
return DOW_ORDER.map((dow) => ({
day: DOW_LABELS[dow],
count: totals[dow],
}));
}
/**
* Renders a responsive bar chart of agent activity grouped by day-of-week
* using recharts. Data is aggregated across all agents.
*
* @param props - AgentHeatmapProps
* @returns JSX element
*/
export default function AgentHeatmap({
data,
}: AgentHeatmapProps): React.ReactElement {
const chartData = useMemo(() => aggregateByDow(data), [data]);
return (
<ResponsiveContainer width="100%" height={300}>
<BarChart
data={chartData}
margin={{ top: 8, right: 16, left: 0, bottom: 8 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
<XAxis
dataKey="day"
tick={{ fontSize: 12, fill: '#64748b' }}
tickLine={false}
axisLine={{ stroke: '#cbd5e1' }}
/>
<YAxis
tick={{ fontSize: 12, fill: '#64748b' }}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<Tooltip
formatter={(value: number) => [value.toLocaleString(), 'Events']}
contentStyle={{
borderRadius: '8px',
border: '1px solid #e2e8f0',
fontSize: '13px',
}}
/>
<Bar dataKey="count" fill="#6366f1" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
);
}

View File

@@ -0,0 +1,99 @@
'use client';
/**
* TokenTrendChart — Recharts LineChart showing daily token issuance counts.
*
* This component is designed to be lazy-loaded via `next/dynamic`. Do NOT
* import it directly from a page; use dynamic(() => import('./TokenTrendChart'))
* so that recharts stays out of the main bundle.
*
* @module components/charts/TokenTrendChart
*/
import React from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
/** A single data point for the token trend line chart. */
export interface TokenTrendDataPoint {
/** ISO 8601 date string (e.g. "2026-03-01"). */
date: string;
/** Number of tokens issued on this date. */
count: number;
}
/** Props for the TokenTrendChart component. */
export interface TokenTrendChartProps {
/** Array of daily token issuance data points, sorted ascending by date. */
data: TokenTrendDataPoint[];
}
/**
* Formats an ISO date string as a short label (e.g. "Mar 1").
*
* @param dateStr - ISO 8601 date string
* @returns Formatted short date label
*/
function formatDateLabel(dateStr: string): string {
const d = new Date(dateStr);
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
/**
* Renders a responsive line chart of daily token issuance counts using recharts.
*
* @param props - TokenTrendChartProps
* @returns JSX element
*/
export default function TokenTrendChart({
data,
}: TokenTrendChartProps): React.ReactElement {
return (
<ResponsiveContainer width="100%" height={300}>
<LineChart
data={data}
margin={{ top: 8, right: 16, left: 0, bottom: 8 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis
dataKey="date"
tickFormatter={formatDateLabel}
tick={{ fontSize: 12, fill: '#64748b' }}
tickLine={false}
axisLine={{ stroke: '#cbd5e1' }}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 12, fill: '#64748b' }}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<Tooltip
formatter={(value: number) => [value.toLocaleString(), 'Tokens issued']}
labelFormatter={(label: string) => formatDateLabel(label)}
contentStyle={{
borderRadius: '8px',
border: '1px solid #e2e8f0',
fontSize: '13px',
}}
/>
<Line
type="monotone"
dataKey="count"
stroke="#6366f1"
strokeWidth={2}
dot={false}
activeDot={{ r: 4, fill: '#6366f1' }}
/>
</LineChart>
</ResponsiveContainer>
);
}

70
portal/hooks/useAuth.ts Normal file
View File

@@ -0,0 +1,70 @@
'use client';
/**
* useAuth — Client-side authentication hook for the SentryAgent portal.
*
* Reads the tenant JWT stored in localStorage under the key
* `sentryagent_token`. If no token is present the hook signals that the user
* is unauthenticated so the calling page can redirect to `/login`.
*
* This is intentionally lightweight: the portal calls the AgentIdP API
* directly; the JWT is issued by the AgentIdP `/api/tenants/login` endpoint
* and stored on successful sign-in.
*
* @module hooks/useAuth
*/
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
/** The localStorage key under which the tenant JWT is persisted. */
export const AUTH_TOKEN_KEY = 'sentryagent_token';
/** Shape returned by the useAuth hook. */
export interface AuthState {
/** The stored JWT, or null if unauthenticated. */
token: string | null;
/** True while the hook is reading from localStorage on mount. */
loading: boolean;
/**
* Sign the user out by removing the stored token and redirecting to /login.
*/
signOut: () => void;
}
/**
* Returns the current authentication state and provides a sign-out helper.
* Redirects to `/login` when no token is found (after the initial mount check).
*
* @param redirectOnUnauth - When true (default), redirects to /login if
* no token is present. Pass false on public pages.
* @returns AuthState
*/
export function useAuth(redirectOnUnauth = true): AuthState {
const router = useRouter();
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const stored =
typeof window !== 'undefined'
? localStorage.getItem(AUTH_TOKEN_KEY)
: null;
setToken(stored);
setLoading(false);
if (!stored && redirectOnUnauth) {
router.replace('/login');
}
}, [redirectOnUnauth, router]);
const signOut = (): void => {
if (typeof window !== 'undefined') {
localStorage.removeItem(AUTH_TOKEN_KEY);
}
setToken(null);
router.replace('/login');
};
return { token, loading, signOut };
}

344
portal/package-lock.json generated
View File

@@ -9,9 +9,11 @@
"version": "1.0.0",
"dependencies": {
"@stoplight/elements": "^9.0.16",
"date-fns": "^3.3.0",
"next": "14.2.5",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"recharts": "^2.10.0"
},
"devDependencies": {
"@types/node": "^20.14.0",
@@ -1418,6 +1420,69 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/har-format": {
"version": "1.2.16",
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
@@ -2020,6 +2085,137 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/date-fns": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2037,6 +2233,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -2174,6 +2376,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -2186,6 +2394,15 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-equals": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -2934,6 +3151,15 @@
"css-in-js-utils": "^3.1.0"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-arguments": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
@@ -4937,6 +5163,47 @@
"react-dom": ">=16.8"
}
},
"node_modules/react-smooth": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"license": "MIT",
"dependencies": {
"fast-equals": "^5.0.1",
"prop-types": "^15.8.1",
"react-transition-group": "^4.4.5"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"license": "BSD-3-Clause",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/react-transition-group/node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/react-universal-interface": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/react-universal-interface/-/react-universal-interface-0.6.2.tgz",
@@ -4969,6 +5236,53 @@
"node": ">=8.10.0"
}
},
"node_modules/recharts": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz",
"integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"eventemitter3": "^4.0.1",
"lodash": "^4.17.21",
"react-is": "^18.3.1",
"react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/recharts-scale": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz",
"integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==",
"license": "MIT",
"dependencies": {
"decimal.js-light": "^2.4.1"
}
},
"node_modules/recharts/node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/recharts/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/remark-frontmatter": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/remark-frontmatter/-/remark-frontmatter-3.0.0.tgz",
@@ -5452,6 +5766,12 @@
"node": ">=10"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -6014,6 +6334,28 @@
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
"integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/web-namespaces": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",

View File

@@ -10,9 +10,11 @@
},
"dependencies": {
"@stoplight/elements": "^9.0.16",
"date-fns": "^3.3.0",
"next": "14.2.5",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"recharts": "^2.10.0"
},
"devDependencies": {
"@types/node": "^20.14.0",

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

View File

@@ -0,0 +1,385 @@
/**
* AGNTCY Conformance Test Suite for SentryAgent.ai AgentIdP.
*
* Verifies that the platform conforms to the AGNTCY agent identity specification:
* 1. Agent registration creates a DID:WEB identifier.
* 2. Token issuance for agent client (client_credentials grant).
* 3. A2A delegation chain create + verify (gated by A2A_ENABLED).
* 4. Compliance report generation returns a valid AGNTCY structure.
*/
import crypto from 'crypto';
import request from 'supertest';
import { Application } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { Pool } from 'pg';
// ── Environment setup — must happen before importing app ──────────────────────
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
process.env['DATABASE_URL'] =
process.env['TEST_DATABASE_URL'] ??
'postgresql://sentryagent:sentryagent@localhost:5432/sentryagent_idp_test';
process.env['REDIS_URL'] = process.env['TEST_REDIS_URL'] ?? 'redis://localhost:6379/1';
process.env['JWT_PRIVATE_KEY'] = privateKey;
process.env['JWT_PUBLIC_KEY'] = publicKey;
process.env['NODE_ENV'] = 'test';
process.env['COMPLIANCE_ENABLED'] = 'true';
// Ensure A2A tests only run when the feature is on
const a2aEnabled = process.env['A2A_ENABLED'] !== 'false';
// ── Imports (after env is set) ────────────────────────────────────────────────
import { createApp } from '../../src/app.js';
import { signToken } from '../../src/utils/jwt.js';
import { closePool } from '../../src/db/pool.js';
import { closeRedisClient } from '../../src/cache/redis.js';
// ── Helpers ───────────────────────────────────────────────────────────────────
/**
* Creates a signed JWT for use in test requests.
*
* @param sub - Subject (agentId).
* @param scope - Space-separated OAuth 2.0 scopes.
* @param organizationId - Optional organization_id claim.
* @returns Signed JWT string.
*/
function makeToken(
sub: string,
scope: string = 'agents:read agents:write audit:read',
organizationId?: string,
): string {
const payload: Record<string, unknown> = { sub, client_id: sub, scope, jti: uuidv4() };
if (organizationId !== undefined) {
payload['organization_id'] = organizationId;
}
return signToken(payload as Parameters<typeof signToken>[0], privateKey);
}
// ── Test suite ────────────────────────────────────────────────────────────────
describe('AGNTCY Conformance Suite', () => {
let app: Application;
let pool: Pool;
// ── Migrations required for conformance tests ─────────────────────────────
const migrations = [
`CREATE TABLE IF NOT EXISTS organizations (
organization_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL UNIQUE,
tier VARCHAR(32) NOT NULL DEFAULT 'free',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS agents (
agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(organization_id),
email VARCHAR(255) NOT NULL UNIQUE,
agent_type VARCHAR(32) NOT NULL,
version VARCHAR(64) NOT NULL,
capabilities TEXT[] NOT NULL DEFAULT '{}',
owner VARCHAR(128) NOT NULL,
deployment_env VARCHAR(16) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'active',
did VARCHAR(512),
did_document JSONB,
did_created_at TIMESTAMPTZ,
is_public BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS credentials (
credential_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_id UUID NOT NULL,
secret_hash VARCHAR(255) NOT NULL,
vault_path VARCHAR(512),
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
)`,
`CREATE TABLE IF NOT EXISTS audit_events (
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID NOT NULL,
organization_id UUID,
action VARCHAR(64) NOT NULL,
outcome VARCHAR(16) NOT NULL,
ip_address VARCHAR(64) NOT NULL DEFAULT '127.0.0.1',
user_agent TEXT NOT NULL DEFAULT 'test',
metadata JSONB NOT NULL DEFAULT '{}',
hash VARCHAR(64) NOT NULL DEFAULT '',
previous_hash VARCHAR(64) NOT NULL DEFAULT '',
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS token_revocations (
jti UUID PRIMARY KEY,
expires_at TIMESTAMPTZ NOT NULL,
revoked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS agent_did_keys (
key_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
agent_id UUID NOT NULL REFERENCES agents(agent_id),
key_type VARCHAR(32) NOT NULL,
public_key TEXT NOT NULL,
private_key_encrypted TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS delegation_chains (
chain_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
delegator_id UUID NOT NULL,
delegatee_id UUID NOT NULL,
scope TEXT NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
token TEXT
)`,
];
beforeAll(async () => {
app = await createApp();
pool = new Pool({ connectionString: process.env['DATABASE_URL'] });
for (const sql of migrations) {
await pool.query(sql);
}
});
afterEach(async () => {
await pool.query('DELETE FROM audit_events');
await pool.query('DELETE FROM credentials');
await pool.query('DELETE FROM agents');
await pool.query('DELETE FROM organizations');
});
afterAll(async () => {
await pool.end();
await closePool();
await closeRedisClient();
});
// ── Conformance test 1: Agent registration creates DID:WEB identifier ─────
describe('Conformance 1 — Agent registration creates DID:WEB identifier', () => {
it('should register an agent and return a did field starting with did:web:', async () => {
const agentId = uuidv4();
const token = makeToken(agentId, 'agents:read agents:write');
const res = await request(app)
.post('/api/v1/agents')
.set('Authorization', `Bearer ${token}`)
.send({
email: `conformance-agent-${agentId}@sentryagent.ai`,
agentType: 'screener',
version: '1.0.0',
capabilities: ['identity:read'],
owner: 'conformance-team',
deploymentEnv: 'development',
});
expect(res.status).toBe(201);
expect(res.body.agentId).toBeDefined();
// Verify DID is present and conforms to did:web: scheme
if (res.body.did !== undefined && res.body.did !== null) {
expect(typeof res.body.did).toBe('string');
expect((res.body.did as string).startsWith('did:web:')).toBe(true);
}
});
});
// ── Conformance test 2: Token issuance via client_credentials grant ────────
describe('Conformance 2 — Token issuance for agent client (client_credentials)', () => {
it('should issue a Bearer JWT via client_credentials grant', async () => {
const agentId = uuidv4();
const setupToken = makeToken(agentId, 'agents:read agents:write');
// Register agent
await pool.query(
`INSERT INTO agents (agent_id, email, agent_type, version, capabilities, owner, deployment_env, status)
VALUES ($1, $2, 'screener', '1.0.0', '{"identity:read"}', 'conformance-team', 'development', 'active')`,
[agentId, `cred-test-${agentId}@sentryagent.ai`],
);
// Generate credentials via API
const credRes = await request(app)
.post(`/api/v1/agents/${agentId}/credentials`)
.set('Authorization', `Bearer ${setupToken}`)
.send({});
expect(credRes.status).toBe(201);
const { clientSecret } = credRes.body as { clientSecret: string };
// Issue token via client_credentials grant
const tokenRes = await request(app)
.post('/api/v1/token')
.type('form')
.send({
grant_type: 'client_credentials',
client_id: agentId,
client_secret: clientSecret,
scope: 'agents:read',
});
expect(tokenRes.status).toBe(200);
expect(tokenRes.body.access_token).toBeDefined();
expect(tokenRes.body.token_type).toBe('Bearer');
expect(typeof tokenRes.body.access_token).toBe('string');
// Verify JWT structure (3 parts separated by dots)
const jwtParts = (tokenRes.body.access_token as string).split('.');
expect(jwtParts).toHaveLength(3);
});
});
// ── Conformance test 3: A2A delegation chain (gated by A2A_ENABLED) ────────
(a2aEnabled ? describe : describe.skip)(
'Conformance 3 — A2A delegation chain create + verify (A2A_ENABLED=true)',
() => {
it('should create and verify a delegation chain between two agents', async () => {
const delegatorId = uuidv4();
const delegateeId = uuidv4();
// Insert both agents directly
await pool.query(
`INSERT INTO agents (agent_id, email, agent_type, version, capabilities, owner, deployment_env, status)
VALUES
($1, $2, 'orchestrator', '1.0.0', '{"agents:delegate"}', 'delegator-team', 'development', 'active'),
($3, $4, 'screener', '1.0.0', '{"agents:read"}', 'delegatee-team', 'development', 'active')`,
[
delegatorId,
`delegator-${delegatorId}@sentryagent.ai`,
delegateeId,
`delegatee-${delegateeId}@sentryagent.ai`,
],
);
const delegatorToken = makeToken(delegatorId, 'agents:read agents:write');
// Create delegation chain
const createRes = await request(app)
.post('/api/v1/oauth2/token/delegate')
.set('Authorization', `Bearer ${delegatorToken}`)
.send({
delegatee_id: delegateeId,
scope: 'agents:read',
});
// Accept 201 (created) or 200 (already exists)
expect([200, 201]).toContain(createRes.status);
const delegationToken: string =
createRes.body.token ??
createRes.body.delegation_token ??
createRes.body.access_token ??
'';
// Verify delegation chain if a token was returned
if (delegationToken !== '') {
const verifyRes = await request(app)
.post('/api/v1/oauth2/token/verify-delegation')
.set('Authorization', `Bearer ${delegatorToken}`)
.send({ token: delegationToken });
expect([200, 204]).toContain(verifyRes.status);
}
});
},
);
// ── Conformance test 4: Compliance report returns valid AGNTCY structure ───
describe('Conformance 4 — Compliance report returns valid AGNTCY structure', () => {
it('should return a compliance report with all required AGNTCY fields', async () => {
const orgId = uuidv4();
const agentId = uuidv4();
// Create organization and agent
await pool.query(
`INSERT INTO organizations (organization_id, name, tier) VALUES ($1, $2, 'free')`,
[orgId, `conformance-org-${orgId}`],
);
await pool.query(
`INSERT INTO agents (agent_id, organization_id, email, agent_type, version, capabilities, owner, deployment_env, status)
VALUES ($1, $2, $3, 'screener', '1.0.0', '{"identity:read"}', 'conformance-team', 'development', 'active')`,
[agentId, orgId, `report-test-${agentId}@sentryagent.ai`],
);
const token = makeToken(agentId, 'agents:read audit:read', orgId);
const res = await request(app)
.get('/api/v1/compliance/report')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
// Verify all required AGNTCY fields are present
expect(res.body.generated_at).toBeDefined();
expect(res.body.tenant_id).toBeDefined();
expect(res.body.agntcy_schema_version).toBe('1.0');
expect(res.body.sections).toBeInstanceOf(Array);
expect(res.body.sections.length).toBeGreaterThan(0);
expect(['pass', 'fail', 'warn']).toContain(res.body.overall_status);
// Verify generated_at is a valid ISO 8601 string
const generatedAt = new Date(res.body.generated_at as string);
expect(generatedAt.getTime()).not.toBeNaN();
// Verify each section has required fields
for (const section of res.body.sections as Array<Record<string, unknown>>) {
expect(typeof section['name']).toBe('string');
expect(['pass', 'fail', 'warn']).toContain(section['status']);
expect(typeof section['details']).toBe('string');
}
// Verify expected sections are present
const sectionNames = (res.body.sections as Array<Record<string, unknown>>).map(
(s) => s['name'],
);
expect(sectionNames).toContain('agent-identity');
expect(sectionNames).toContain('audit-trail');
});
it('should return X-Cache: HIT on second request within cache window', async () => {
const orgId = uuidv4();
const agentId = uuidv4();
await pool.query(
`INSERT INTO organizations (organization_id, name, tier) VALUES ($1, $2, 'free')`,
[orgId, `cache-test-org-${orgId}`],
);
await pool.query(
`INSERT INTO agents (agent_id, organization_id, email, agent_type, version, capabilities, owner, deployment_env, status)
VALUES ($1, $2, $3, 'screener', '1.0.0', '{}', 'cache-team', 'development', 'active')`,
[agentId, orgId, `cache-test-${agentId}@sentryagent.ai`],
);
const token = makeToken(agentId, 'agents:read audit:read', orgId);
// First request — populates cache
await request(app)
.get('/api/v1/compliance/report')
.set('Authorization', `Bearer ${token}`);
// Second request — should be served from cache
const secondRes = await request(app)
.get('/api/v1/compliance/report')
.set('Authorization', `Bearer ${token}`);
expect(secondRes.status).toBe(200);
expect(secondRes.headers['x-cache']).toBe('HIT');
expect(secondRes.body.from_cache).toBe(true);
});
});
});

View File

@@ -0,0 +1,7 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
rootDir: '.',
testMatch: ['**/*.test.ts'],
moduleNameMapper: { '^(\\.{1,2}/.*)\\.js$': '$1' },
};

View File

@@ -35,9 +35,9 @@ describe('metricsRegistry', () => {
expect(metricsRegistry).not.toBe(register);
});
it('contains exactly 14 metric entries', async () => {
it('contains exactly 19 metric entries', async () => {
const entries = await metricsRegistry.getMetricsAsJSON();
expect(entries).toHaveLength(14);
expect(entries).toHaveLength(19);
});
// ──────────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,164 @@
/**
* Unit tests for src/services/AnalyticsService.ts
*/
import { Pool, QueryResult } from 'pg';
import { AnalyticsService } from '../../../src/services/AnalyticsService';
// ── Mock pg Pool ──────────────────────────────────────────────────────────────
function makePool(queryFn: jest.Mock): Pool {
return { query: queryFn } as unknown as Pool;
}
// ════════════════════════════════════════════════════════════════════════════
// AnalyticsService
// ════════════════════════════════════════════════════════════════════════════
describe('AnalyticsService', () => {
beforeEach(() => jest.clearAllMocks());
// ────────────────────────────────────────────────────────────────
// recordEvent()
// ────────────────────────────────────────────────────────────────
describe('recordEvent()', () => {
it('should call pool.query with the upsert SQL on success', async () => {
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
const service = new AnalyticsService(makePool(mockQuery));
await service.recordEvent('tenant-1', 'token_issued');
expect(mockQuery).toHaveBeenCalledTimes(1);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('INSERT INTO analytics_events'),
['tenant-1', 'token_issued'],
);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('ON CONFLICT'),
expect.any(Array),
);
});
it('should silently swallow errors — never rejects or throws', async () => {
const mockQuery = jest.fn().mockRejectedValue(new Error('DB connection failed'));
const service = new AnalyticsService(makePool(mockQuery));
// Must not throw — fire-and-forget contract
await expect(service.recordEvent('tenant-err', 'token_issued')).resolves.toBeUndefined();
});
});
// ────────────────────────────────────────────────────────────────
// getTokenTrend()
// ────────────────────────────────────────────────────────────────
describe('getTokenTrend()', () => {
it('should cap days at 90 (MAX_TREND_DAYS)', async () => {
const mockQuery = jest.fn().mockResolvedValue({
rows: [{ date: '2026-04-04', count: '5' }],
} as unknown as QueryResult);
const service = new AnalyticsService(makePool(mockQuery));
await service.getTokenTrend('tenant-1', 200);
// The first positional parameter passed to pool.query should be 90, not 200
const callArgs = mockQuery.mock.calls[0] as [string, unknown[]];
expect(callArgs[1][0]).toBe(90);
});
it('should return mapped rows with date and count as numbers', async () => {
const mockQuery = jest.fn().mockResolvedValue({
rows: [
{ date: '2026-04-01', count: '3' },
{ date: '2026-04-02', count: '7' },
{ date: '2026-04-03', count: '0' },
],
} as unknown as QueryResult);
const service = new AnalyticsService(makePool(mockQuery));
const result = await service.getTokenTrend('tenant-1', 3);
expect(result).toHaveLength(3);
expect(result[0]).toEqual({ date: '2026-04-01', count: 3 });
expect(result[1]).toEqual({ date: '2026-04-02', count: 7 });
expect(result[2]).toEqual({ date: '2026-04-03', count: 0 });
// count must be a number, not a string
expect(typeof result[0].count).toBe('number');
});
});
// ────────────────────────────────────────────────────────────────
// getAgentActivity()
// ────────────────────────────────────────────────────────────────
describe('getAgentActivity()', () => {
it('should return rows mapped to correct IAgentActivityEntry shape', async () => {
const mockQuery = jest.fn().mockResolvedValue({
rows: [
{ agent_id: 'agent-uuid-1', dow: '1', hour: '0', count: '12' },
{ agent_id: 'agent-uuid-1', dow: '3', hour: '0', count: '5' },
{ agent_id: 'agent-uuid-2', dow: '5', hour: '0', count: '20' },
],
} as unknown as QueryResult);
const service = new AnalyticsService(makePool(mockQuery));
const result = await service.getAgentActivity('tenant-1');
expect(result).toHaveLength(3);
expect(result[0]).toEqual({ agent_id: 'agent-uuid-1', dow: 1, hour: 0, count: 12 });
expect(result[1]).toEqual({ agent_id: 'agent-uuid-1', dow: 3, hour: 0, count: 5 });
expect(result[2]).toEqual({ agent_id: 'agent-uuid-2', dow: 5, hour: 0, count: 20 });
// Numeric types
expect(typeof result[0].dow).toBe('number');
expect(typeof result[0].hour).toBe('number');
expect(typeof result[0].count).toBe('number');
});
it('should return an empty array when no activity rows exist', async () => {
const mockQuery = jest.fn().mockResolvedValue({
rows: [],
} as unknown as QueryResult);
const service = new AnalyticsService(makePool(mockQuery));
const result = await service.getAgentActivity('tenant-empty');
expect(result).toEqual([]);
});
});
// ────────────────────────────────────────────────────────────────
// getAgentUsageSummary()
// ────────────────────────────────────────────────────────────────
describe('getAgentUsageSummary()', () => {
it('should return rows mapped to correct IAgentUsageSummaryEntry shape', async () => {
const mockQuery = jest.fn().mockResolvedValue({
rows: [
{ agent_id: 'agent-uuid-1', name: 'team-a', token_count: '200' },
{ agent_id: 'agent-uuid-2', name: 'team-b', token_count: '50' },
],
} as unknown as QueryResult);
const service = new AnalyticsService(makePool(mockQuery));
const result = await service.getAgentUsageSummary('tenant-1');
expect(result).toHaveLength(2);
expect(result[0]).toEqual({ agent_id: 'agent-uuid-1', name: 'team-a', token_count: 200 });
expect(result[1]).toEqual({ agent_id: 'agent-uuid-2', name: 'team-b', token_count: 50 });
// token_count must be a number
expect(typeof result[0].token_count).toBe('number');
});
it('should return an empty array when no agents exist', async () => {
const mockQuery = jest.fn().mockResolvedValue({
rows: [],
} as unknown as QueryResult);
const service = new AnalyticsService(makePool(mockQuery));
const result = await service.getAgentUsageSummary('tenant-empty');
expect(result).toEqual([]);
});
});
});

View File

@@ -0,0 +1,271 @@
/**
* Unit tests for src/services/ComplianceService.ts
*/
import { Pool, QueryResult } from 'pg';
import type { RedisClientType } from 'redis';
import { ComplianceService } from '../../../src/services/ComplianceService';
// ── Mock AuditVerificationService (instantiated internally by ComplianceService) ──
jest.mock('../../../src/services/AuditVerificationService', () => {
return {
AuditVerificationService: jest.fn().mockImplementation(() => ({
verifyChain: jest.fn().mockResolvedValue({
verified: true,
checkedCount: 42,
brokenAtEventId: null,
}),
})),
};
});
// ── Re-import after mock is established ──────────────────────────────────────
import { AuditVerificationService } from '../../../src/services/AuditVerificationService';
const MockAuditVerificationService = AuditVerificationService as jest.MockedClass<
typeof AuditVerificationService
>;
// ── Mock helpers ──────────────────────────────────────────────────────────────
function makePool(queryFn: jest.Mock): Pool {
return { query: queryFn } as unknown as Pool;
}
function makeRedis(overrides: Partial<{
getFn: jest.Mock;
setFn: jest.Mock;
}>): RedisClientType {
return {
get: overrides.getFn ?? jest.fn().mockResolvedValue(null),
set: overrides.setFn ?? jest.fn().mockResolvedValue('OK'),
} as unknown as RedisClientType;
}
// ════════════════════════════════════════════════════════════════════════════
// ComplianceService
// ════════════════════════════════════════════════════════════════════════════
describe('ComplianceService', () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset AuditVerificationService mock to default passing behaviour
MockAuditVerificationService.mockImplementation(
() =>
({
verifyChain: jest.fn().mockResolvedValue({
verified: true,
checkedCount: 42,
brokenAtEventId: null,
}),
}) as unknown as InstanceType<typeof AuditVerificationService>,
);
});
// ────────────────────────────────────────────────────────────────
// generateReport() — cache miss
// ────────────────────────────────────────────────────────────────
describe('generateReport() — cache miss', () => {
it('should build a report, store it in Redis, and return IComplianceReport structure', async () => {
// Cache miss → null
const getFn = jest.fn().mockResolvedValue(null);
const setFn = jest.fn().mockResolvedValue('OK');
// Pool returns empty agents list → agent-identity section passes trivially
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
const service = new ComplianceService(makePool(mockQuery), makeRedis({ getFn, setFn }));
const report = await service.generateReport('tenant-1');
// Redis cache miss check
expect(getFn).toHaveBeenCalledWith('compliance:report:tenant-1');
// Report stored in Redis after build
expect(setFn).toHaveBeenCalledWith(
'compliance:report:tenant-1',
expect.any(String),
expect.objectContaining({ EX: 300 }),
);
// IComplianceReport structure
expect(report).toMatchObject({
tenant_id: 'tenant-1',
agntcy_schema_version: '1.0',
sections: expect.any(Array),
overall_status: expect.stringMatching(/^(pass|fail|warn)$/),
generated_at: expect.any(String),
});
// from_cache should be absent on a freshly built report
expect(report.from_cache).toBeUndefined();
});
});
// ────────────────────────────────────────────────────────────────
// generateReport() — cache hit
// ────────────────────────────────────────────────────────────────
describe('generateReport() — cache hit', () => {
it('should return cached data with from_cache: true', async () => {
const cachedReport = {
generated_at: '2026-04-04T00:00:00.000Z',
tenant_id: 'tenant-cache',
agntcy_schema_version: '1.0',
sections: [{ name: 'agent-identity', status: 'pass', details: 'All good.' }],
overall_status: 'pass',
};
const getFn = jest.fn().mockResolvedValue(JSON.stringify(cachedReport));
const setFn = jest.fn();
const mockQuery = jest.fn();
const service = new ComplianceService(makePool(mockQuery), makeRedis({ getFn, setFn }));
const report = await service.generateReport('tenant-cache');
expect(report.from_cache).toBe(true);
expect(report.tenant_id).toBe('tenant-cache');
expect(report.overall_status).toBe('pass');
// No DB queries should be made on a cache hit
expect(mockQuery).not.toHaveBeenCalled();
// Redis set should not be called either
expect(setFn).not.toHaveBeenCalled();
});
});
// ────────────────────────────────────────────────────────────────
// generateReport() — overall_status rollup
// ────────────────────────────────────────────────────────────────
describe('generateReport() — overall_status', () => {
it('should return overall_status: pass when all sections pass', async () => {
const getFn = jest.fn().mockResolvedValue(null);
const setFn = jest.fn().mockResolvedValue('OK');
// No agents → agent-identity passes trivially
// AuditVerificationService mock returns verified: true (default in beforeEach)
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
const service = new ComplianceService(makePool(mockQuery), makeRedis({ getFn, setFn }));
const report = await service.generateReport('tenant-all-pass');
expect(report.overall_status).toBe('pass');
});
it('should return overall_status: fail when any section fails', async () => {
const getFn = jest.fn().mockResolvedValue(null);
const setFn = jest.fn().mockResolvedValue('OK');
// Override AuditVerificationService to simulate broken chain → audit-trail fails
MockAuditVerificationService.mockImplementation(
() =>
({
verifyChain: jest.fn().mockResolvedValue({
verified: false,
checkedCount: 10,
brokenAtEventId: 'event-uuid-broken',
}),
}) as unknown as InstanceType<typeof AuditVerificationService>,
);
// No agents so agent-identity is 'pass'; audit-trail will be 'fail'
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
const service = new ComplianceService(makePool(mockQuery), makeRedis({ getFn, setFn }));
const report = await service.generateReport('tenant-fail');
expect(report.overall_status).toBe('fail');
const auditSection = report.sections.find((s) => s.name === 'audit-trail');
expect(auditSection?.status).toBe('fail');
});
});
// ────────────────────────────────────────────────────────────────
// exportAgentCards()
// ────────────────────────────────────────────────────────────────
describe('exportAgentCards()', () => {
it('should return an array of IAgentCard objects with correct fields', async () => {
const createdAt = new Date('2026-01-15T12:00:00Z');
const agentRows = [
{
agent_id: 'agent-uuid-1',
owner: 'team-alpha',
capabilities: ['agents:read', 'tokens:issue'],
created_at: createdAt,
did: 'did:web:sentryagent.ai:agent-uuid-1',
},
{
agent_id: 'agent-uuid-2',
owner: 'team-beta',
capabilities: ['agents:read'],
created_at: createdAt,
did: null, // no DID — id should fall back to agent_id
},
];
const mockQuery = jest.fn().mockResolvedValue({
rows: agentRows,
} as unknown as QueryResult);
const service = new ComplianceService(
makePool(mockQuery),
makeRedis({ getFn: jest.fn(), setFn: jest.fn() }),
);
const cards = await service.exportAgentCards('tenant-1');
expect(cards).toHaveLength(2);
// Card with DID uses DID as id
expect(cards[0]).toEqual({
id: 'did:web:sentryagent.ai:agent-uuid-1',
name: 'team-alpha',
capabilities: ['agents:read', 'tokens:issue'],
endpoint: 'https://api.sentryagent.ai/agents/agent-uuid-1',
created_at: createdAt.toISOString(),
agntcy_schema_version: '1.0',
});
// Card without DID falls back to agent_id as id
expect(cards[1]).toEqual({
id: 'agent-uuid-2',
name: 'team-beta',
capabilities: ['agents:read'],
endpoint: 'https://api.sentryagent.ai/agents/agent-uuid-2',
created_at: createdAt.toISOString(),
agntcy_schema_version: '1.0',
});
});
it('should return an empty array when no active agents exist', async () => {
const mockQuery = jest.fn().mockResolvedValue({
rows: [],
} as unknown as QueryResult);
const service = new ComplianceService(
makePool(mockQuery),
makeRedis({ getFn: jest.fn(), setFn: jest.fn() }),
);
const cards = await service.exportAgentCards('tenant-empty');
expect(cards).toEqual([]);
});
it('should query only non-decommissioned agents scoped to the tenantId', async () => {
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
const service = new ComplianceService(
makePool(mockQuery),
makeRedis({ getFn: jest.fn(), setFn: jest.fn() }),
);
await service.exportAgentCards('tenant-scope-test');
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining("status != 'decommissioned'"),
['tenant-scope-test'],
);
});
});
});

View File

@@ -0,0 +1,250 @@
/**
* Unit tests for src/services/TierService.ts
*/
import { Pool, QueryResult } from 'pg';
import Stripe from 'stripe';
import type { RedisClientType } from 'redis';
import { TierService } from '../../../src/services/TierService';
import { ValidationError, TierLimitError } from '../../../src/utils/errors';
// ── Mock helpers ──────────────────────────────────────────────────────────────
function makePool(queryFn: jest.Mock): Pool {
return { query: queryFn } as unknown as Pool;
}
function makeRedis(getFn: jest.Mock): RedisClientType {
return { get: getFn } as unknown as RedisClientType;
}
function makeStripe(overrides: Partial<{
checkoutUrl: string;
}> = {}): Stripe {
const url = overrides.checkoutUrl ?? 'https://checkout.stripe.com/tier_upgrade_test';
return {
checkout: {
sessions: {
create: jest.fn().mockResolvedValue({ url, id: 'cs_tier_1' }),
},
},
} as unknown as Stripe;
}
// ════════════════════════════════════════════════════════════════════════════
// TierService
// ════════════════════════════════════════════════════════════════════════════
describe('TierService', () => {
beforeEach(() => jest.clearAllMocks());
// ────────────────────────────────────────────────────────────────
// getStatus()
// ────────────────────────────────────────────────────────────────
describe('getStatus()', () => {
it('should return correct ITierStatus shape with tier, limits, usage, and resetAt', async () => {
// Pool: tier query, then agent count query
const mockQuery = jest.fn()
.mockResolvedValueOnce({ rows: [{ tier: 'pro' }] } as unknown as QueryResult)
.mockResolvedValueOnce({ rows: [{ count: '7' }] } as unknown as QueryResult);
const mockGet = jest.fn()
.mockResolvedValueOnce('123') // callsToday
.mockResolvedValueOnce('456'); // tokensToday
const service = new TierService(makePool(mockQuery), makeRedis(mockGet), makeStripe());
const status = await service.getStatus('org-uuid-1');
expect(status.tier).toBe('pro');
expect(status.limits).toEqual({
maxAgents: 100,
maxCallsPerDay: 50_000,
maxTokensPerDay: 50_000,
});
expect(status.usage).toEqual({
callsToday: 123,
tokensToday: 456,
agentCount: 7,
});
expect(typeof status.resetAt).toBe('string');
// resetAt must be a valid ISO 8601 timestamp
expect(new Date(status.resetAt).toString()).not.toBe('Invalid Date');
});
it('should read usage from Redis keys rate:tier:calls:<orgId> and rate:tier:tokens:<orgId>', async () => {
const mockQuery = jest.fn()
.mockResolvedValueOnce({ rows: [{ tier: 'free' }] } as unknown as QueryResult)
.mockResolvedValueOnce({ rows: [{ count: '3' }] } as unknown as QueryResult);
const mockGet = jest.fn().mockResolvedValue('0');
const service = new TierService(makePool(mockQuery), makeRedis(mockGet), makeStripe());
await service.getStatus('org-redis-test');
expect(mockGet).toHaveBeenCalledWith('rate:tier:calls:org-redis-test');
expect(mockGet).toHaveBeenCalledWith('rate:tier:tokens:org-redis-test');
});
it('should default to free tier when no organization row is found', async () => {
const mockQuery = jest.fn()
.mockResolvedValueOnce({ rows: [] } as unknown as QueryResult) // no org row
.mockResolvedValueOnce({ rows: [{ count: '0' }] } as unknown as QueryResult);
const mockGet = jest.fn().mockResolvedValue(null);
const service = new TierService(makePool(mockQuery), makeRedis(mockGet), makeStripe());
const status = await service.getStatus('org-unknown');
expect(status.tier).toBe('free');
expect(status.limits.maxAgents).toBe(10);
});
it('should return 0 for Redis counters when Redis keys are absent (null)', async () => {
const mockQuery = jest.fn()
.mockResolvedValueOnce({ rows: [{ tier: 'free' }] } as unknown as QueryResult)
.mockResolvedValueOnce({ rows: [{ count: '2' }] } as unknown as QueryResult);
const mockGet = jest.fn().mockResolvedValue(null);
const service = new TierService(makePool(mockQuery), makeRedis(mockGet), makeStripe());
const status = await service.getStatus('org-no-redis');
expect(status.usage.callsToday).toBe(0);
expect(status.usage.tokensToday).toBe(0);
});
});
// ────────────────────────────────────────────────────────────────
// initiateUpgrade()
// ────────────────────────────────────────────────────────────────
describe('initiateUpgrade()', () => {
it('should throw ValidationError if already on target tier', async () => {
const mockQuery = jest.fn().mockResolvedValue({
rows: [{ tier: 'pro' }],
} as unknown as QueryResult);
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), makeStripe());
await expect(service.initiateUpgrade('org-1', 'pro')).rejects.toThrow(ValidationError);
});
it('should throw ValidationError when downgrade is attempted (pro → free)', async () => {
const mockQuery = jest.fn().mockResolvedValue({
rows: [{ tier: 'pro' }],
} as unknown as QueryResult);
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), makeStripe());
await expect(service.initiateUpgrade('org-1', 'free')).rejects.toThrow(ValidationError);
});
it('should create a Stripe checkout session and return checkoutUrl', async () => {
const mockQuery = jest.fn().mockResolvedValue({
rows: [{ tier: 'free' }],
} as unknown as QueryResult);
const stripe = makeStripe({ checkoutUrl: 'https://checkout.stripe.com/upgrade' });
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), stripe);
const result = await service.initiateUpgrade('org-free', 'pro');
expect(result.checkoutUrl).toBe('https://checkout.stripe.com/upgrade');
expect(stripe.checkout.sessions.create).toHaveBeenCalledWith(
expect.objectContaining({
mode: 'subscription',
client_reference_id: 'org-free',
metadata: expect.objectContaining({ orgId: 'org-free', targetTier: 'pro' }),
}),
);
});
it('should throw when Stripe returns no session URL', async () => {
const mockQuery = jest.fn().mockResolvedValue({
rows: [{ tier: 'free' }],
} as unknown as QueryResult);
const stripe = {
checkout: {
sessions: {
create: jest.fn().mockResolvedValue({ url: null, id: 'cs_no_url' }),
},
},
} as unknown as Stripe;
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), stripe);
await expect(service.initiateUpgrade('org-free', 'pro')).rejects.toThrow(
'Stripe did not return a checkout session URL.',
);
});
});
// ────────────────────────────────────────────────────────────────
// applyUpgrade()
// ────────────────────────────────────────────────────────────────
describe('applyUpgrade()', () => {
it('should update the organizations table tier column', async () => {
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), makeStripe());
await service.applyUpgrade('org-upgrade', 'pro');
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('UPDATE organizations'),
['pro', 'org-upgrade'],
);
expect(mockQuery).toHaveBeenCalledWith(
expect.stringContaining('tier_updated_at'),
expect.any(Array),
);
});
it('should resolve without throwing on success', async () => {
const mockQuery = jest.fn().mockResolvedValue({ rows: [] } as unknown as QueryResult);
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), makeStripe());
await expect(service.applyUpgrade('org-upgrade', 'enterprise')).resolves.toBeUndefined();
});
});
// ────────────────────────────────────────────────────────────────
// enforceAgentLimit()
// ────────────────────────────────────────────────────────────────
describe('enforceAgentLimit()', () => {
it('should throw TierLimitError when agent count is at or above the limit', async () => {
// Agent count query returns 10 (= free tier max of 10)
const mockQuery = jest.fn().mockResolvedValue({
rows: [{ count: '10' }],
} as unknown as QueryResult);
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), makeStripe());
await expect(service.enforceAgentLimit('org-limited', 'free')).rejects.toThrow(TierLimitError);
});
it('should not throw when agent count is below the limit', async () => {
const mockQuery = jest.fn().mockResolvedValue({
rows: [{ count: '5' }],
} as unknown as QueryResult);
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), makeStripe());
await expect(service.enforceAgentLimit('org-ok', 'free')).resolves.toBeUndefined();
});
it('should never throw for enterprise tier (Infinity limit)', async () => {
// pool.query should NOT be called because Infinity bypasses the check
const mockQuery = jest.fn();
const service = new TierService(makePool(mockQuery), makeRedis(jest.fn()), makeStripe());
await expect(service.enforceAgentLimit('org-enterprise', 'enterprise')).resolves.toBeUndefined();
// No DB query needed for Infinity limit
expect(mockQuery).not.toHaveBeenCalled();
});
});
});