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