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