feat: add Aphoria dashboard scans and corpus UI

- Add scans panel with finding details, verdict badges, and filters
- Add corpus panel for managing knowledge sources
- Add scan cache for API state management
- Update sidebar navigation with new routes
- Extend API types for scans and corpus endpoints
- Add .aphoria/ to gitignore (contains project keys)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-02-07 15:56:49 -07:00
parent 3ce37573b8
commit c849627620
42 changed files with 1634 additions and 27 deletions

View File

@ -14,6 +14,16 @@ You are an expert at installing and running StemeDB and Aphoria. You guide users
- User is troubleshooting installation or runtime issues
- User needs to verify their installation is working
## Vision: Autonomous Learning
Aphoria's goal is **autonomous pattern learning**:
- Scan projects → extract claims about what code does
- Compare against authorities → RFCs, OWASP, specs
- Learn from usage → patterns feed back to corpus
- Autonomous promotion → good patterns become authoritative automatically
**Hooks are not optional.** They trigger the learning loop. Every commit = scan = observation = learning.
## Principles
### 1. User Space First
@ -25,8 +35,8 @@ Aphoria standalone needs only Rust. StemeDB server is optional for solo develope
### 3. Fast Verification
Every installation step has an immediate verification command.
### 4. Ephemeral by Default
Default scans are fast and ephemeral. Server/persistence is opt-in.
### 4. Hooks Are Mandatory
When setting up on a project, **always install pre-commit hooks**. Without hooks, no autonomous learning happens. Don't ask - just do it.
### 5. Progressive Disclosure
Start with minimal setup, add complexity only when needed.
@ -215,19 +225,64 @@ curl http://localhost:18180/health
# Expected: {"status":"ok"}
```
### Phase 6 (Optional): Configure Hosted Mode
### Phase 6: Set Up Project (MANDATORY)
When installing Aphoria on any project, **always do all three**:
```bash
# 1. Create config
cat > aphoria.toml << 'EOF'
[project]
name = "my-project"
description = "Brief description"
[scan]
domains = ["security", "cryptography"]
[thresholds]
flag = 0.4
block = 0.6
[learning]
persist = true
sync = true
EOF
# 2. Install pre-commit hook (NOT OPTIONAL)
mkdir -p .git/hooks
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/bash
aphoria scan --staged --exit-code
if [ $? -ne 0 ]; then
echo "Aphoria found conflicts."
exit 1
fi
EOF
chmod +x .git/hooks/pre-commit
# 3. Create documentation
cat > aphoria.md << 'EOF'
# Aphoria Integration
This project uses Aphoria for autonomous security pattern learning.
Every commit triggers a scan. Observations feed back to the corpus.
See aphoria.toml for configuration.
EOF
# 4. Run baseline
aphoria scan --persist --sync
```
**Do not skip the hook.** It's the trigger for autonomous learning.
### Phase 7 (Optional): Configure Hosted Mode
Connect Aphoria to StemeDB server:
```bash
# In project directory
cat > aphoria.toml << 'EOF'
[project]
name = "my-project"
# Add to aphoria.toml
[hosted]
url = "http://localhost:18180"
EOF
# Test connection
aphoria scan --persist --sync

3
.gitignore vendored
View File

@ -23,6 +23,9 @@ Thumbs.db
credentials.json
service-account*.json
# Aphoria project data (contains keys)
.aphoria/
# Python virtual environments
.venv/
venv/

View File

@ -61,7 +61,7 @@ dirs = "5.0"
# Logging
tracing = "0.1"
tracing-subscriber = "0.3"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# rkyv for zero-copy (consistent with stemedb)
rkyv = { version = "0.7", features = ["validation"] }

View File

@ -36,6 +36,10 @@ pub struct Cli {
#[arg(short, long, global = true)]
pub config: Option<PathBuf>,
/// Enable verbose logging (shows internal tracing output)
#[arg(short, long, global = true)]
pub verbose: bool,
#[command(subcommand)]
pub command: Commands,
}

View File

@ -159,6 +159,8 @@ impl AuditTrail {
}
/// Get all audit events.
///
/// Resilient parser that skips malformed lines rather than failing.
pub fn get_all_events(&self) -> Result<Vec<AuditEvent>, AphoriaError> {
let path = self.events_path();
if !path.exists() {
@ -170,17 +172,53 @@ impl AuditTrail {
let reader = BufReader::new(file);
let mut events = Vec::new();
let mut skipped = 0;
for line in reader.lines() {
let line = line
.map_err(|e| AphoriaError::Storage(format!("Failed to read audit line: {}", e)))?;
let line = match line {
Ok(l) => l,
Err(_) => {
skipped += 1;
continue;
}
};
if line.trim().is_empty() {
continue;
}
let event: AuditEvent = serde_json::from_str(&line)
.map_err(|e| AphoriaError::Storage(format!("Failed to parse event: {}", e)))?;
events.push(event);
// Try to parse each JSON object on the line (handles concatenated objects)
let mut remaining = line.trim();
while !remaining.is_empty() {
// Find the end of the current JSON object
match serde_json::from_str::<AuditEvent>(remaining) {
Ok(event) => {
events.push(event);
// Consumed the whole line
break;
}
Err(_) => {
// Try to find where the first JSON object ends by looking for }{
if let Some(split_pos) = remaining.find("}{") {
let first_obj = &remaining[..=split_pos];
if let Ok(event) = serde_json::from_str::<AuditEvent>(first_obj) {
events.push(event);
} else {
skipped += 1;
}
remaining = &remaining[split_pos + 1..];
} else {
// Can't parse, skip this line
skipped += 1;
break;
}
}
}
}
}
if skipped > 0 {
tracing::warn!(skipped, "Skipped malformed audit entries");
}
Ok(events)

View File

@ -23,12 +23,12 @@ pub async fn show_status(config: &AphoriaConfig) -> Result<String, AphoriaError>
}
output.push_str("Aphoria status:\n");
output.push_str(&format!(" Data directory: {}\\n", data_dir.display()));
output.push_str(&format!(" Project root: {}\\n", project_root.display()));
output.push_str(&format!(" Data directory: {}\n", data_dir.display()));
output.push_str(&format!(" Project root: {}\n", project_root.display()));
if aphoria_dir.join("baseline").exists() {
let baseline = std::fs::read_to_string(aphoria_dir.join("baseline"))?;
output.push_str(&format!(" Baseline: {}\\n", baseline.trim()));
output.push_str(&format!(" Baseline: {}\n", baseline.trim()));
} else {
output.push_str(" Baseline: none\n");
}

View File

@ -6,6 +6,7 @@
use std::process::ExitCode;
use clap::Parser;
use tracing_subscriber::EnvFilter;
use aphoria::AphoriaConfig;
@ -16,11 +17,16 @@ use cli::Cli;
#[tokio::main]
async fn main() -> ExitCode {
// Initialize tracing for internal logging
tracing_subscriber::fmt::init();
let cli = Cli::parse();
// Initialize tracing only if verbose or RUST_LOG is set
// Default: silent (clean CLI output)
if cli.verbose || std::env::var("RUST_LOG").is_ok() {
let filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("aphoria=info"));
tracing_subscriber::fmt().with_env_filter(filter).init();
}
// Load configuration
let config = match load_config(cli.config.as_deref()) {
Ok(cfg) => cfg,

View File

@ -0,0 +1,13 @@
import { Header } from "@/components/layout/header";
import { CorpusPanel } from "@/components/corpus";
export default function CorpusPage() {
return (
<>
<Header title="Community Corpus" />
<div className="p-6">
<CorpusPanel />
</div>
</>
);
}

View File

@ -0,0 +1,13 @@
import { Header } from "@/components/layout/header";
import { ScansPanel } from "@/components/scans";
export default function ScansPage() {
return (
<>
<Header title="Aphoria Scans" />
<div className="p-6">
<ScansPanel />
</div>
</>
);
}

View File

@ -0,0 +1,20 @@
// Corpus page constants
export const CORPUS_FETCH_LIMIT = 100;
export const DEFAULT_MIN_PROJECTS = 1;
// Re-export shared formatters for convenience
export { formatRelativeTime, formatUnixTimestamp } from "@/lib/format";
// Subject path parsing
export function extractDomain(subject: string): string {
// Extract domain from code://rust/*/tls → rust
const match = subject.match(/^code:\/\/([^/]+)/);
return match ? match[1] : "unknown";
}
export function extractConcept(subject: string): string {
// Extract last part of path: code://rust/*/tls/cert → cert
const parts = subject.split("/");
return parts[parts.length - 1] || subject;
}

View File

@ -0,0 +1,43 @@
"use client";
import { Library, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
interface CorpusEmptyStateProps {
hasFilter: boolean;
onClearFilter?: () => void;
}
export function CorpusEmptyState({ hasFilter, onClearFilter }: CorpusEmptyStateProps) {
if (hasFilter) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Search className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
No patterns match your filter
</h3>
<p className="text-sm text-muted-foreground mb-4 max-w-md">
Try adjusting the subject prefix or lowering the minimum projects threshold.
</p>
{onClearFilter && (
<Button variant="outline" onClick={onClearFilter}>
Clear Filters
</Button>
)}
</div>
);
}
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Library className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
No community patterns yet
</h3>
<p className="text-sm text-muted-foreground max-w-md">
When teams opt-in to community sharing, their anonymized scan patterns
will appear here. Run scans with community sharing enabled to contribute.
</p>
</div>
);
}

View File

@ -0,0 +1,71 @@
"use client";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
interface CorpusFiltersProps {
subjectPrefix: string;
minProjects: number;
onSubjectPrefixChange: (value: string) => void;
onMinProjectsChange: (value: number) => void;
totalCount: number;
filteredCount: number;
}
export function CorpusFilters({
subjectPrefix,
minProjects,
onSubjectPrefixChange,
onMinProjectsChange,
totalCount,
filteredCount,
}: CorpusFiltersProps) {
const hasActiveFilter = subjectPrefix !== "" || minProjects > 1;
const handleClear = () => {
onSubjectPrefixChange("");
onMinProjectsChange(1);
};
return (
<div className="flex flex-wrap items-center gap-4">
<div className="flex-1 min-w-[200px]">
<Input
placeholder="Filter by subject prefix (e.g., code://rust)"
value={subjectPrefix}
onChange={(e) => onSubjectPrefixChange(e.target.value)}
className="max-w-md"
/>
</div>
<div className="flex items-center gap-2">
<label htmlFor="min-projects" className="text-sm text-muted-foreground whitespace-nowrap">
Min projects:
</label>
<Input
id="min-projects"
type="number"
min={1}
max={100}
value={minProjects}
onChange={(e) => onMinProjectsChange(Math.max(1, parseInt(e.target.value) || 1))}
className="w-20"
/>
</div>
{hasActiveFilter && (
<Button variant="ghost" size="sm" onClick={handleClear}>
<X className="h-4 w-4 mr-1" />
Clear
</Button>
)}
<div className="text-sm text-muted-foreground ml-auto">
{filteredCount === totalCount
? `${totalCount} patterns`
: `${filteredCount} of ${totalCount} patterns`}
</div>
</div>
);
}

View File

@ -0,0 +1,21 @@
"use client";
import type { PatternDto } from "@/lib/api";
import { CorpusRow } from "./corpus-row";
interface CorpusListProps {
patterns: PatternDto[];
}
export function CorpusList({ patterns }: CorpusListProps) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{patterns.map((pattern) => (
<CorpusRow
key={`${pattern.subject}:${pattern.predicate}:${pattern.value}`}
pattern={pattern}
/>
))}
</div>
);
}

View File

@ -0,0 +1,29 @@
"use client";
export function CorpusLoadingSkeleton() {
return (
<div className="space-y-4 animate-pulse">
{/* Filter skeleton */}
<div className="flex gap-4">
<div className="h-10 w-64 bg-muted rounded-md" />
<div className="h-10 w-32 bg-muted rounded-md" />
</div>
{/* Pattern cards skeleton */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="rounded-lg border border-border bg-card p-4">
<div className="space-y-3">
<div className="h-5 w-3/4 bg-muted rounded" />
<div className="h-4 w-1/2 bg-muted rounded" />
<div className="flex gap-2 mt-4">
<div className="h-6 w-20 bg-muted rounded-full" />
<div className="h-6 w-20 bg-muted rounded-full" />
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,129 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import {
StemeDBClient,
type GetPatternsResponse,
ApiError,
} from "@/lib/api";
import type { PanelState } from "@/lib/types";
import { CORPUS_FETCH_LIMIT, DEFAULT_MIN_PROJECTS } from "./constants";
import { ErrorState } from "@/components/shared/error-state";
import { CorpusFilters } from "./corpus-filters";
import { CorpusList } from "./corpus-list";
import { CorpusLoadingSkeleton } from "./corpus-loading-skeleton";
import { CorpusEmptyState } from "./corpus-empty-state";
export function CorpusPanel() {
const [state, setState] = useState<PanelState<GetPatternsResponse>>({
status: "idle",
});
const [subjectPrefix, setSubjectPrefix] = useState("");
const [minProjects, setMinProjects] = useState(DEFAULT_MIN_PROJECTS);
// Debounced filter values for API calls
const [debouncedPrefix, setDebouncedPrefix] = useState("");
// Debounce the subject prefix
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedPrefix(subjectPrefix);
}, 300);
return () => clearTimeout(timer);
}, [subjectPrefix]);
const fetchData = useCallback(async () => {
setState({ status: "loading" });
try {
const client = new StemeDBClient();
const data = await client.getPatterns({
subjectPrefix: debouncedPrefix || undefined,
minProjects,
limit: CORPUS_FETCH_LIMIT,
});
setState({ status: "success", data });
} catch (err) {
// 404 means no patterns - treat as empty success
if (err instanceof ApiError && err.status === 404) {
setState({
status: "success",
data: { patterns: [], total_matching: 0 },
});
return;
}
const message =
err instanceof ApiError
? err.userMessage
: err instanceof Error
? err.message
: "Unknown error";
setState({ status: "error", error: message });
}
}, [debouncedPrefix, minProjects]);
// Fetch on mount and when filters change
useEffect(() => {
fetchData();
}, [fetchData]);
// Patterns from successful state (filtering done server-side)
const patterns = state.status === "success" ? state.data.patterns : [];
const handleClearFilter = useCallback(() => {
setSubjectPrefix("");
setMinProjects(DEFAULT_MIN_PROJECTS);
}, []);
const hasFilter = subjectPrefix !== "" || minProjects > DEFAULT_MIN_PROJECTS;
return (
<div className="space-y-6">
{/* Header */}
<div className="rounded-lg border border-border bg-card p-6">
<h2 className="text-lg font-medium text-card-foreground mb-2">
Community Corpus
</h2>
<p className="text-sm text-muted-foreground">
Explore patterns discovered across projects using Aphoria. These anonymized
observations help establish community consensus on configurations and practices.
</p>
</div>
{/* Content */}
<div className="rounded-lg border border-border bg-card p-6">
{state.status === "idle" && <CorpusLoadingSkeleton />}
{state.status === "loading" && <CorpusLoadingSkeleton />}
{state.status === "error" && (
<ErrorState
title="Failed to Load Patterns"
error={state.error}
onRetry={fetchData}
/>
)}
{state.status === "success" && (
<div className="space-y-6">
<CorpusFilters
subjectPrefix={subjectPrefix}
minProjects={minProjects}
onSubjectPrefixChange={setSubjectPrefix}
onMinProjectsChange={setMinProjects}
totalCount={state.data.total_matching}
filteredCount={patterns.length}
/>
{patterns.length === 0 ? (
<CorpusEmptyState
hasFilter={hasFilter}
onClearFilter={handleClearFilter}
/>
) : (
<CorpusList patterns={patterns} />
)}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,69 @@
"use client";
import { cn } from "@/lib/utils";
import type { PatternDto } from "@/lib/api";
import { formatRelativeTime, extractDomain, extractConcept } from "./constants";
import { Badge } from "@/components/ui/badge";
import { Users, Clock, Eye } from "lucide-react";
interface CorpusRowProps {
pattern: PatternDto;
className?: string;
}
export function CorpusRow({ pattern, className }: CorpusRowProps) {
const domain = extractDomain(pattern.subject);
const concept = extractConcept(pattern.subject);
return (
<div
className={cn(
"rounded-lg border border-border bg-card p-4 hover:border-border/80 transition-colors",
className
)}
>
{/* Header */}
<div className="flex items-start justify-between gap-2 mb-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline" className="text-xs font-mono">
{domain}
</Badge>
<span className="text-xs text-muted-foreground truncate">
{pattern.subject}
</span>
</div>
<h3 className="text-base font-medium text-foreground">
{concept}
<span className="text-muted-foreground font-normal">
{" "}.{pattern.predicate}
</span>
</h3>
</div>
</div>
{/* Value */}
<div className="mb-4">
<code className="text-sm bg-muted px-2 py-1 rounded font-mono break-all">
{pattern.value}
</code>
</div>
{/* Stats */}
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<Users className="h-3.5 w-3.5" />
<span>{pattern.project_count} projects</span>
</div>
<div className="flex items-center gap-1">
<Eye className="h-3.5 w-3.5" />
<span>{pattern.observation_count} observations</span>
</div>
<div className="flex items-center gap-1 ml-auto">
<Clock className="h-3.5 w-3.5" />
<span>Last seen {formatRelativeTime(pattern.last_seen)}</span>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,7 @@
export { CorpusPanel } from "./corpus-panel";
export { CorpusFilters } from "./corpus-filters";
export { CorpusList } from "./corpus-list";
export { CorpusRow } from "./corpus-row";
export { CorpusEmptyState } from "./corpus-empty-state";
export { CorpusLoadingSkeleton } from "./corpus-loading-skeleton";
export * from "./constants";

View File

@ -12,6 +12,8 @@ import {
Menu,
X,
BookOpen,
Scan,
Library,
} from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/utils";
@ -23,6 +25,8 @@ const navigation = [
{ name: "Quarantine", href: "/quarantine", icon: ShieldAlert },
{ name: "Circuit Breakers", href: "/circuit", icon: Zap },
{ name: "Audit Trail", href: "/audit", icon: FileText },
{ name: "Corpus", href: "/corpus", icon: Library },
{ name: "Scans", href: "/scans", icon: Scan },
];
export function Sidebar() {

View File

@ -0,0 +1,27 @@
// Scans page constants
export type VerdictType = "BLOCK" | "FLAG" | "PASS" | "ACK";
export const verdictLabels: Record<VerdictType, string> = {
BLOCK: "Block",
FLAG: "Flag",
PASS: "Pass",
ACK: "Acknowledged",
};
export const verdictColors: Record<VerdictType, string> = {
BLOCK: "bg-red-500/20 text-red-700 dark:text-red-300",
FLAG: "bg-amber-500/20 text-amber-700 dark:text-amber-300",
PASS: "bg-emerald-500/20 text-emerald-700 dark:text-emerald-300",
ACK: "bg-blue-500/20 text-blue-700 dark:text-blue-300",
};
export const verdictIcons: Record<VerdictType, string> = {
BLOCK: "\u25CF", // ●
FLAG: "\u26A0", // ⚠
PASS: "\u2713", // ✓
ACK: "\u2714", // ✔
};
// Re-export shared formatters for convenience
export { formatRelativeTime, formatUnixDateTime } from "@/lib/format";

View File

@ -0,0 +1,167 @@
"use client";
import type { FindingDto } from "@/lib/api";
import { VerdictBadge } from "./verdict-badge";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from "@/components/ui/sheet";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { FileCode, AlertTriangle, BookOpen, Shield } from "lucide-react";
interface FindingDetailSheetProps {
finding: FindingDto | null;
isOpen: boolean;
onClose: () => void;
}
export function FindingDetailSheet({ finding, isOpen, onClose }: FindingDetailSheetProps) {
if (!finding) return null;
return (
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose()}>
<SheetContent className="overflow-y-auto">
<SheetHeader className="pb-4">
<div className="flex items-center gap-2">
<VerdictBadge verdict={finding.verdict} />
</div>
<SheetTitle className="text-lg font-semibold break-words">
{finding.concept_path}
</SheetTitle>
<SheetDescription className="text-sm">
<span className="font-mono">.{finding.predicate}</span>
</SheetDescription>
</SheetHeader>
<div className="space-y-6">
{/* Code Location */}
<section>
<h3 className="flex items-center gap-2 text-sm font-medium text-foreground mb-3">
<FileCode className="h-4 w-4" />
Source Location
</h3>
<div className="rounded-md bg-muted p-3 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">File:</span>
<code className="font-mono text-foreground">{finding.file}</code>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Line:</span>
<span className="font-mono text-foreground">{finding.line}</span>
</div>
<Separator className="my-2" />
<div>
<span className="text-sm text-muted-foreground block mb-1">Value in code:</span>
<code className="block bg-background p-2 rounded font-mono text-sm break-all">
{finding.code_value}
</code>
</div>
</div>
</section>
{/* Conflict Details */}
{finding.conflicts.length > 0 && (
<section>
<h3 className="flex items-center gap-2 text-sm font-medium text-foreground mb-3">
<AlertTriangle className="h-4 w-4 text-amber-500" />
Conflicting Sources ({finding.conflicts.length})
</h3>
<div className="space-y-3">
{finding.conflicts.map((conflict, index) => (
<div
key={index}
className="rounded-md border border-border bg-card p-3 space-y-2"
>
<div className="flex items-center justify-between">
<Badge variant="outline" className="text-xs">
{conflict.source_class}
</Badge>
{conflict.policy_source && (
<span className="text-xs text-muted-foreground">
via {conflict.policy_source.pack_name} v{conflict.policy_source.pack_version}
</span>
)}
</div>
<div className="text-sm">
<span className="text-muted-foreground">Expected: </span>
<code className="font-mono bg-muted px-1 rounded">{conflict.value}</code>
</div>
{conflict.citation && (
<div className="flex items-start gap-2 text-xs text-muted-foreground">
<BookOpen className="h-3 w-3 mt-0.5 flex-shrink-0" />
<span>{conflict.citation}</span>
</div>
)}
</div>
))}
</div>
</section>
)}
{/* Acknowledgment */}
{finding.acknowledgment && (
<section>
<h3 className="flex items-center gap-2 text-sm font-medium text-foreground mb-3">
<Shield className="h-4 w-4 text-blue-500" />
Acknowledged
</h3>
<div className="rounded-md bg-blue-500/10 border border-blue-500/20 p-3 space-y-2">
<div className="text-sm">
<span className="text-muted-foreground">By: </span>
<span className="text-foreground">{finding.acknowledgment.by}</span>
</div>
<div className="text-sm">
<span className="text-muted-foreground">Reason: </span>
<span className="text-foreground">{finding.acknowledgment.reason}</span>
</div>
<div className="text-xs text-muted-foreground">
{finding.acknowledgment.timestamp}
</div>
</div>
</section>
)}
{/* Debug Trace */}
{finding.trace && (
<section>
<h3 className="text-sm font-medium text-foreground mb-3">
Resolution Trace
</h3>
<div className="rounded-md bg-muted p-3 space-y-2 text-xs font-mono">
<div>
<span className="text-muted-foreground">Code claim: </span>
<span className="text-foreground">{finding.trace.code_claim}</span>
</div>
<div>
<span className="text-muted-foreground">Authority match: </span>
<span className="text-foreground">{finding.trace.authority_match}</span>
</div>
<div>
<span className="text-muted-foreground">Authority tier: </span>
<span className="text-foreground">{finding.trace.authority_tier}</span>
</div>
<Separator className="my-2" />
<div>
<span className="text-muted-foreground">Resolution: </span>
<span className="text-foreground">{finding.trace.resolution}</span>
</div>
</div>
</section>
)}
{/* Conflict Score */}
<section>
<div className="flex justify-between items-center text-sm">
<span className="text-muted-foreground">Conflict Score</span>
<span className="font-mono">{(finding.conflict_score * 100).toFixed(1)}%</span>
</div>
</section>
</div>
</SheetContent>
</Sheet>
);
}

View File

@ -0,0 +1,61 @@
"use client";
import { cn } from "@/lib/utils";
import type { FindingDto } from "@/lib/api";
import { VerdictBadge } from "./verdict-badge";
import { FileCode, ChevronRight } from "lucide-react";
interface FindingRowProps {
finding: FindingDto;
onClick?: () => void;
className?: string;
}
export function FindingRow({ finding, onClick, className }: FindingRowProps) {
// Extract last part of concept path for display
const conceptParts = finding.concept_path.split("/");
const conceptShort = conceptParts[conceptParts.length - 1] || finding.concept_path;
return (
<div
className={cn(
"flex items-center gap-3 p-3 rounded-md border border-border bg-card/50 hover:bg-card/80 transition-colors cursor-pointer",
className
)}
onClick={onClick}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onClick?.();
}
}}
>
{/* Icon */}
<div className="flex-shrink-0">
<FileCode className="h-4 w-4 text-muted-foreground" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm text-foreground truncate">
{conceptShort}
<span className="text-muted-foreground">.{finding.predicate}</span>
</span>
<VerdictBadge verdict={finding.verdict} size="xs" />
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="truncate">{finding.file}:{finding.line}</span>
<span className="text-muted-foreground/50">|</span>
<code className="bg-muted px-1 rounded truncate max-w-[200px]">
{finding.code_value}
</code>
</div>
</div>
{/* Arrow */}
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
</div>
);
}

View File

@ -0,0 +1,11 @@
export { ScansPanel } from "./scans-panel";
export { ScanForm } from "./scan-form";
export { ScansList } from "./scans-list";
export { ScanRow } from "./scan-row";
export { ScanDetail } from "./scan-detail";
export { FindingRow } from "./finding-row";
export { FindingDetailSheet } from "./finding-detail-sheet";
export { VerdictBadge } from "./verdict-badge";
export { ScansEmptyState } from "./scans-empty-state";
export { ScansLoadingSkeleton } from "./scans-loading-skeleton";
export * from "./constants";

View File

@ -0,0 +1,31 @@
"use client";
import type { FindingDto } from "@/lib/api";
import { FindingRow } from "./finding-row";
interface ScanDetailProps {
findings: FindingDto[];
onFindingClick?: (finding: FindingDto) => void;
}
export function ScanDetail({ findings, onFindingClick }: ScanDetailProps) {
if (findings.length === 0) {
return (
<div className="text-sm text-muted-foreground py-4 text-center">
No findings in this scan.
</div>
);
}
return (
<div className="space-y-2 mt-4">
{findings.map((finding, index) => (
<FindingRow
key={`${finding.concept_path}-${finding.file}-${finding.line}-${index}`}
finding={finding}
onClick={() => onFindingClick?.(finding)}
/>
))}
</div>
);
}

View File

@ -0,0 +1,51 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Loader2, Scan } from "lucide-react";
interface ScanFormProps {
onScan: (targetPath: string) => Promise<void>;
isScanning: boolean;
}
export function ScanForm({ onScan, isScanning }: ScanFormProps) {
const [targetPath, setTargetPath] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!targetPath.trim() || isScanning) return;
await onScan(targetPath.trim());
};
return (
<form onSubmit={handleSubmit} className="flex gap-4 items-end">
<div className="flex-1">
<label htmlFor="target-path" className="block text-sm font-medium text-foreground mb-2">
Project Path
</label>
<Input
id="target-path"
placeholder="/path/to/your/project"
value={targetPath}
onChange={(e) => setTargetPath(e.target.value)}
disabled={isScanning}
/>
</div>
<Button type="submit" disabled={isScanning || !targetPath.trim()}>
{isScanning ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Scanning...
</>
) : (
<>
<Scan className="h-4 w-4 mr-2" />
Run Scan
</>
)}
</Button>
</form>
);
}

View File

@ -0,0 +1,101 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import type { ScanListItem, FindingDto } from "@/lib/api";
import { formatRelativeTime } from "./constants";
import { VerdictBadge } from "./verdict-badge";
import { ScanDetail } from "./scan-detail";
import { ChevronDown, ChevronRight, FolderCode, FileSearch } from "lucide-react";
import { Badge } from "@/components/ui/badge";
interface ScanRowProps {
scan: ScanListItem;
onFindingClick?: (finding: FindingDto) => void;
className?: string;
}
export function ScanRow({ scan, onFindingClick, className }: ScanRowProps) {
const [expanded, setExpanded] = useState(false);
const hasIssues = scan.summary.blocked > 0 || scan.summary.flagged > 0;
return (
<div
className={cn(
"rounded-lg border border-border bg-card overflow-hidden",
hasIssues && "border-amber-500/30",
className
)}
>
{/* Header */}
<div
className={cn(
"flex items-center gap-4 p-4 cursor-pointer hover:bg-muted/30 transition-colors",
expanded && "border-b border-border"
)}
onClick={() => setExpanded(!expanded)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
setExpanded(!expanded);
}
}}
>
{/* Expand icon */}
<div className="flex-shrink-0 text-muted-foreground">
{expanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</div>
{/* Project info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<FolderCode className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="font-medium text-foreground truncate">{scan.project}</span>
<span className="text-xs text-muted-foreground">
{formatRelativeTime(scan.timestamp)}
</span>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<FileSearch className="h-3 w-3" />
{scan.files_scanned} files
</span>
<span>{scan.claims_extracted} claims</span>
</div>
</div>
{/* Summary badges */}
<div className="flex items-center gap-2 flex-shrink-0">
{scan.summary.blocked > 0 && (
<Badge variant="destructive" className="text-xs">
{scan.summary.blocked} blocked
</Badge>
)}
{scan.summary.flagged > 0 && (
<Badge variant="outline" className="text-xs border-amber-500/50 text-amber-600">
{scan.summary.flagged} flagged
</Badge>
)}
{scan.summary.blocked === 0 && scan.summary.flagged === 0 && (
<Badge variant="outline" className="text-xs border-emerald-500/50 text-emerald-600">
Clean
</Badge>
)}
</div>
</div>
{/* Expanded detail */}
{expanded && (
<div className="p-4 bg-muted/20">
<ScanDetail findings={scan.findings} onFindingClick={onFindingClick} />
</div>
)}
</div>
);
}

View File

@ -0,0 +1,18 @@
"use client";
import { Scan } from "lucide-react";
export function ScansEmptyState() {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Scan className="h-12 w-12 text-muted-foreground/50 mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
No scans yet
</h3>
<p className="text-sm text-muted-foreground max-w-md">
Run your first scan by entering a project path above. Scans check your
code for conflicts against authoritative sources.
</p>
</div>
);
}

View File

@ -0,0 +1,23 @@
"use client";
import type { ScanListItem, FindingDto } from "@/lib/api";
import { ScanRow } from "./scan-row";
interface ScansListProps {
scans: ScanListItem[];
onFindingClick?: (finding: FindingDto) => void;
}
export function ScansList({ scans, onFindingClick }: ScansListProps) {
return (
<div className="space-y-3">
{scans.map((scan) => (
<ScanRow
key={scan.scan_id}
scan={scan}
onFindingClick={onFindingClick}
/>
))}
</div>
);
}

View File

@ -0,0 +1,35 @@
"use client";
export function ScansLoadingSkeleton() {
return (
<div className="space-y-4 animate-pulse">
{/* Scan form skeleton */}
<div className="rounded-lg border border-border bg-card p-4">
<div className="flex gap-4 items-end">
<div className="flex-1 space-y-2">
<div className="h-4 w-24 bg-muted rounded" />
<div className="h-10 bg-muted rounded" />
</div>
<div className="h-10 w-24 bg-muted rounded" />
</div>
</div>
{/* Scan list skeleton */}
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="rounded-lg border border-border bg-card p-4">
<div className="flex items-center justify-between mb-3">
<div className="h-5 w-40 bg-muted rounded" />
<div className="h-6 w-20 bg-muted rounded" />
</div>
<div className="flex gap-4">
<div className="h-4 w-32 bg-muted rounded" />
<div className="h-4 w-32 bg-muted rounded" />
<div className="h-4 w-32 bg-muted rounded" />
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,140 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import {
StemeDBClient,
type ListScansResponse,
type FindingDto,
ApiError,
} from "@/lib/api";
import type { PanelState } from "@/lib/types";
import { ErrorState } from "@/components/shared/error-state";
import { ScanForm } from "./scan-form";
import { ScansList } from "./scans-list";
import { ScansLoadingSkeleton } from "./scans-loading-skeleton";
import { ScansEmptyState } from "./scans-empty-state";
import { FindingDetailSheet } from "./finding-detail-sheet";
export function ScansPanel() {
const [state, setState] = useState<PanelState<ListScansResponse>>({
status: "idle",
});
const [isScanning, setIsScanning] = useState(false);
const [scanError, setScanError] = useState<string | null>(null);
const [selectedFinding, setSelectedFinding] = useState<FindingDto | null>(null);
const fetchData = useCallback(async () => {
setState({ status: "loading" });
try {
const client = new StemeDBClient();
const data = await client.listScans();
setState({ status: "success", data });
} catch (err) {
// 404 means no scans - treat as empty success
if (err instanceof ApiError && err.status === 404) {
setState({
status: "success",
data: { scans: [] },
});
return;
}
const message =
err instanceof ApiError
? err.userMessage
: err instanceof Error
? err.message
: "Unknown error";
setState({ status: "error", error: message });
}
}, []);
// Fetch on mount
useEffect(() => {
fetchData();
}, [fetchData]);
const handleScan = useCallback(async (targetPath: string) => {
setIsScanning(true);
setScanError(null);
try {
const client = new StemeDBClient();
await client.runScan({ target_path: targetPath });
// Refresh the list to show the new scan
await fetchData();
} catch (err) {
const message =
err instanceof ApiError
? err.userMessage
: err instanceof Error
? err.message
: "Scan failed";
setScanError(message);
} finally {
setIsScanning(false);
}
}, [fetchData]);
const handleFindingClick = useCallback((finding: FindingDto) => {
setSelectedFinding(finding);
}, []);
const handleCloseSheet = useCallback(() => {
setSelectedFinding(null);
}, []);
return (
<div className="space-y-6">
{/* Header */}
<div className="rounded-lg border border-border bg-card p-6">
<h2 className="text-lg font-medium text-card-foreground mb-2">
Aphoria Scans
</h2>
<p className="text-sm text-muted-foreground">
Run scans to check your code for conflicts against authoritative sources.
View scan history and drill into findings for details.
</p>
</div>
{/* Scan Form */}
<div className="rounded-lg border border-border bg-card p-6">
<ScanForm onScan={handleScan} isScanning={isScanning} />
{scanError && (
<div className="mt-4 p-3 rounded-md bg-red-500/10 border border-red-500/20 text-sm text-red-600 dark:text-red-400">
{scanError}
</div>
)}
</div>
{/* Content */}
<div className="rounded-lg border border-border bg-card p-6">
<h3 className="text-sm font-medium text-foreground mb-4">Recent Scans</h3>
{state.status === "idle" && <ScansLoadingSkeleton />}
{state.status === "loading" && <ScansLoadingSkeleton />}
{state.status === "error" && (
<ErrorState
title="Failed to Load Scans"
error={state.error}
onRetry={fetchData}
/>
)}
{state.status === "success" && (
state.data.scans.length === 0 ? (
<ScansEmptyState />
) : (
<ScansList scans={state.data.scans} onFindingClick={handleFindingClick} />
)
)}
</div>
{/* Finding Detail Sheet */}
<FindingDetailSheet
finding={selectedFinding}
isOpen={selectedFinding !== null}
onClose={handleCloseSheet}
/>
</div>
);
}

View File

@ -0,0 +1,32 @@
"use client";
import { cn } from "@/lib/utils";
import { type VerdictType, verdictLabels, verdictColors, verdictIcons } from "./constants";
interface VerdictBadgeProps {
verdict: VerdictType;
className?: string;
size?: "sm" | "xs";
}
export function VerdictBadge({ verdict, className, size = "sm" }: VerdictBadgeProps) {
const label = verdictLabels[verdict];
const color = verdictColors[verdict];
const icon = verdictIcons[verdict];
const sizeClass = size === "xs" ? "text-[10px] px-1.5 py-0.5" : "text-xs px-2.5 py-0.5";
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded font-medium",
color,
sizeClass,
className
)}
>
<span className={size === "xs" ? "text-[8px]" : "text-[10px]"}>{icon}</span>
{label}
</span>
);
}

View File

@ -10,6 +10,10 @@ import {
type SourceImpactResponse,
type QuarantineSourceResponse,
type RestoreSourceResponse,
type GetPatternsResponse,
type ScanRequest,
type ScanResponse,
type ListScansResponse,
} from "./types";
export class StemeDBClient {
@ -162,6 +166,31 @@ export class StemeDBClient {
getApiKey(): string | null {
return this.apiKey;
}
// Aphoria methods
async getPatterns(params: {
subjectPrefix?: string;
minProjects?: number;
limit?: number;
} = {}): Promise<GetPatternsResponse> {
const searchParams = new URLSearchParams();
if (params.subjectPrefix) searchParams.set("subject_prefix", params.subjectPrefix);
if (params.minProjects !== undefined) searchParams.set("min_projects", String(params.minProjects));
if (params.limit !== undefined) searchParams.set("limit", String(params.limit));
const query = searchParams.toString();
return this.fetch<GetPatternsResponse>(`/v1/aphoria/patterns${query ? `?${query}` : ""}`);
}
async runScan(request: ScanRequest): Promise<ScanResponse> {
return this.fetch<ScanResponse>("/v1/aphoria/scan", {
method: "POST",
body: JSON.stringify(request),
});
}
async listScans(): Promise<ListScansResponse> {
return this.fetch<ListScansResponse>("/v1/aphoria/scans");
}
}
// Singleton client for server components

View File

@ -244,6 +244,103 @@ export interface RestoreSourceResponse {
message: string;
}
// ============================================================================
// Aphoria DTOs
// ============================================================================
export interface PatternDto {
subject: string;
predicate: string;
value: string;
project_count: number;
observation_count: number;
first_seen: number;
last_seen: number;
}
export interface GetPatternsResponse {
patterns: PatternDto[];
total_matching: number;
}
export interface FindingDto {
concept_path: string;
predicate: string;
code_value: string;
file: string;
line: number;
conflict_score: number;
verdict: "BLOCK" | "FLAG" | "PASS" | "ACK";
conflicts: ConflictingSourceDto[];
acknowledgment?: AcknowledgmentDto;
trace?: ConflictTraceDto;
}
export interface ConflictingSourceDto {
path: string;
source_class: string;
value: string;
citation?: string;
policy_source?: PolicySourceDto;
}
export interface PolicySourceDto {
pack_name: string;
pack_version: string;
issuer_hex: string;
}
export interface AcknowledgmentDto {
timestamp: string;
by: string;
reason: string;
}
export interface ConflictTraceDto {
code_claim: string;
authority_match: string;
authority_tier: string;
resolution: string;
}
export interface ScanSummaryDto {
total: number;
blocked: number;
flagged: number;
passed: number;
acknowledged: number;
}
export interface ScanResponse {
project: string;
scan_id: string;
files_scanned: number;
claims_extracted: number;
findings: FindingDto[];
summary: ScanSummaryDto;
}
export interface ScanRequest {
target_path: string;
format?: string;
fail_on_flag?: boolean;
debug?: boolean;
}
export interface ScanListItem {
scan_id: string;
project: string;
files_scanned: number;
claims_extracted: number;
summary: ScanSummaryDto;
timestamp: number;
findings: FindingDto[];
}
export interface ListScansResponse {
scans: ScanListItem[];
}
export class ApiError extends Error {
public userMessage: string;

View File

@ -2,6 +2,7 @@
/**
* Format a timestamp as a relative time string (e.g., "2h ago", "5m ago")
* @param timestamp - Timestamp in milliseconds
*/
export function formatTimeAgo(timestamp: number): string {
const now = Date.now();
@ -16,6 +17,53 @@ export function formatTimeAgo(timestamp: number): string {
return "just now";
}
/**
* Format a Unix timestamp (seconds) as a relative time string.
* Used for API responses that return Unix timestamps.
* @param timestamp - Unix timestamp in seconds
*/
export function formatRelativeTime(timestamp: number): string {
const now = Date.now() / 1000;
const diff = now - timestamp;
if (diff < 60) return "just now";
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
return formatUnixTimestamp(timestamp);
}
/**
* Format a Unix timestamp (seconds) as a date string.
* @param timestamp - Unix timestamp in seconds
* @param options - Intl.DateTimeFormatOptions for customization
*/
export function formatUnixTimestamp(
timestamp: number,
options?: Intl.DateTimeFormatOptions
): string {
const defaultOptions: Intl.DateTimeFormatOptions = {
month: "short",
day: "numeric",
year: "numeric",
};
return new Date(timestamp * 1000).toLocaleDateString("en-US", options ?? defaultOptions);
}
/**
* Format a Unix timestamp (seconds) as a date-time string.
* @param timestamp - Unix timestamp in seconds
*/
export function formatUnixDateTime(timestamp: number): string {
return new Date(timestamp * 1000).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
/**
* Format a timestamp as time (HH:MM)
*/

View File

@ -8,7 +8,7 @@ description = "HTTP API for Episteme (StemeDB)"
workspace = true
[features]
default = []
default = ["aphoria"]
aphoria = ["dep:aphoria"]
[dependencies]

View File

@ -144,3 +144,39 @@ pub struct GetPatternsResponse {
/// Total number of patterns matching (before limit applied).
pub total_matching: usize,
}
// ============================================================================
// Scan History Endpoint DTOs
// ============================================================================
/// A single scan from the scan history.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ScanListItem {
/// Unique scan ID.
pub scan_id: String,
/// Project name.
pub project: String,
/// Number of files scanned.
pub files_scanned: usize,
/// Number of claims extracted.
pub claims_extracted: usize,
/// Summary counts by verdict.
pub summary: ScanSummaryDto,
/// Unix timestamp of when the scan was performed.
pub timestamp: u64,
/// Findings (conflicts detected).
pub findings: Vec<FindingDto>,
}
/// Response containing recent scan history.
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ListScansResponse {
/// Recent scans, newest first.
pub scans: Vec<ScanListItem>,
}

View File

@ -13,4 +13,4 @@ pub(crate) mod scan;
// Re-export all public handlers to preserve API
pub use policy::{bless, export_policy, import_policy};
pub use report::{get_patterns, push_community_observations, push_observations};
pub use scan::scan;
pub use scan::{list_scans, scan};

View File

@ -1,12 +1,13 @@
//! Project scanning handlers for conflict detection.
use axum::{http::StatusCode, Json};
use axum::{extract::State, http::StatusCode, Json};
use std::path::PathBuf;
use tracing::instrument;
use crate::{
dto::aphoria::{FindingDto, ScanRequest, ScanResponse, ScanSummaryDto},
dto::aphoria::{FindingDto, ListScansResponse, ScanRequest, ScanResponse, ScanSummaryDto},
error::{ApiError, Result},
state::AppState,
};
use super::super::aphoria_helpers::conflict_result_to_dto;
@ -30,7 +31,10 @@ use super::super::aphoria_helpers::conflict_result_to_dto;
tag = "aphoria"
)]
#[instrument(skip_all, fields(target_path = %req.target_path, format = %req.format))]
pub async fn scan(Json(req): Json<ScanRequest>) -> Result<(StatusCode, Json<ScanResponse>)> {
pub async fn scan(
State(state): State<AppState>,
Json(req): Json<ScanRequest>,
) -> Result<(StatusCode, Json<ScanResponse>)> {
let target_path = PathBuf::from(&req.target_path);
// Check path exists
@ -87,6 +91,9 @@ pub async fn scan(Json(req): Json<ScanRequest>) -> Result<(StatusCode, Json<Scan
summary,
};
// Cache the scan result
state.scan_cache.record(&response).await;
// Return 422 if fail_on_flag is true and there are blocks
let status = if req.fail_on_flag && has_blocks {
StatusCode::UNPROCESSABLE_ENTITY
@ -96,3 +103,21 @@ pub async fn scan(Json(req): Json<ScanRequest>) -> Result<(StatusCode, Json<Scan
Ok((status, Json(response)))
}
/// List recent scan results from cache.
///
/// Returns the last 100 scans, newest first.
#[utoipa::path(
get,
path = "/v1/aphoria/scans",
responses(
(status = 200, description = "Scan history retrieved successfully", body = ListScansResponse),
(status = 500, description = "Internal server error", body = crate::dto::ErrorResponse),
),
tag = "aphoria"
)]
#[instrument(skip_all)]
pub async fn list_scans(State(state): State<AppState>) -> Result<Json<ListScansResponse>> {
let response = state.scan_cache.list().await;
Ok(Json(response))
}

View File

@ -77,6 +77,6 @@ pub use metrics::metrics_handler;
#[cfg(feature = "aphoria")]
pub use aphoria::{
bless, export_policy, get_patterns, import_policy, push_community_observations,
bless, export_policy, get_patterns, import_policy, list_scans, push_community_observations,
push_observations, scan,
};

View File

@ -36,6 +36,8 @@ pub mod handlers;
pub mod hex;
pub mod middleware;
mod routers;
#[cfg(feature = "aphoria")]
pub mod scan_cache;
pub mod services;
pub mod state;

View File

@ -379,6 +379,7 @@ fn build_api_routes() -> Router<AppState> {
.route("/v1/aphoria/policy/export", post(handlers::export_policy))
.route("/v1/aphoria/policy/import", post(handlers::import_policy))
.route("/v1/aphoria/scan", post(handlers::scan))
.route("/v1/aphoria/scans", get(handlers::list_scans))
.route("/v1/aphoria/observations", post(handlers::push_observations))
// Community corpus endpoints
.route(

View File

@ -0,0 +1,138 @@
//! In-memory scan result cache for the Aphoria scan history endpoint.
//!
//! Caches the last N scan results to provide a scan history API.
use std::collections::VecDeque;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::sync::RwLock;
use crate::dto::aphoria::{ListScansResponse, ScanListItem, ScanResponse, ScanSummaryDto};
/// Maximum number of scans to cache.
const MAX_CACHED_SCANS: usize = 100;
/// Thread-safe scan cache that stores recent scan results.
#[derive(Clone)]
pub struct ScanCache {
inner: Arc<RwLock<ScanCacheInner>>,
}
struct ScanCacheInner {
scans: VecDeque<ScanListItem>,
}
impl Default for ScanCache {
fn default() -> Self {
Self::new()
}
}
impl ScanCache {
/// Create a new empty scan cache.
pub fn new() -> Self {
Self {
inner: Arc::new(RwLock::new(ScanCacheInner {
scans: VecDeque::with_capacity(MAX_CACHED_SCANS),
})),
}
}
/// Record a new scan result.
///
/// Adds the scan to the front of the cache, removing the oldest
/// scan if we exceed MAX_CACHED_SCANS.
pub async fn record(&self, response: &ScanResponse) {
let timestamp =
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let item = ScanListItem {
scan_id: response.scan_id.clone(),
project: response.project.clone(),
files_scanned: response.files_scanned,
claims_extracted: response.claims_extracted,
summary: ScanSummaryDto {
total: response.summary.total,
blocked: response.summary.blocked,
flagged: response.summary.flagged,
passed: response.summary.passed,
acknowledged: response.summary.acknowledged,
},
timestamp,
findings: response.findings.clone(),
};
let mut inner = self.inner.write().await;
inner.scans.push_front(item);
if inner.scans.len() > MAX_CACHED_SCANS {
inner.scans.pop_back();
}
}
/// List all cached scans, newest first.
pub async fn list(&self) -> ListScansResponse {
let inner = self.inner.read().await;
ListScansResponse { scans: inner.scans.iter().cloned().collect() }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dto::aphoria::ScanSummaryDto;
fn make_scan(id: &str) -> ScanResponse {
ScanResponse {
project: "test-project".to_string(),
scan_id: id.to_string(),
files_scanned: 10,
claims_extracted: 5,
findings: vec![],
summary: ScanSummaryDto {
total: 0,
blocked: 0,
flagged: 0,
passed: 0,
acknowledged: 0,
},
}
}
#[tokio::test]
async fn test_record_and_list() {
let cache = ScanCache::new();
cache.record(&make_scan("scan-1")).await;
cache.record(&make_scan("scan-2")).await;
let list = cache.list().await;
assert_eq!(list.scans.len(), 2);
assert_eq!(list.scans[0].scan_id, "scan-2");
assert_eq!(list.scans[1].scan_id, "scan-1");
}
#[tokio::test]
async fn test_cache_eviction() {
let cache = ScanCache::new();
// Fill cache beyond limit (MAX_CACHED_SCANS = 100)
for i in 0..105 {
cache.record(&make_scan(&format!("scan-{}", i))).await;
}
let list = cache.list().await;
// Should be capped at MAX_CACHED_SCANS
assert_eq!(list.scans.len(), MAX_CACHED_SCANS);
// Newest first
assert_eq!(list.scans[0].scan_id, "scan-104");
// Oldest retained (first 5 were evicted: scan-0 through scan-4)
assert_eq!(list.scans[99].scan_id, "scan-5");
}
#[tokio::test]
async fn test_empty_cache() {
let cache = ScanCache::new();
let list = cache.list().await;
assert!(list.scans.is_empty());
}
}

View File

@ -12,6 +12,9 @@ use stemedb_storage::{
use stemedb_wal::group_commit::{GroupCommitBuffer, GroupCommitConfig};
use stemedb_wal::Journal;
#[cfg(feature = "aphoria")]
use crate::scan_cache::ScanCache;
/// Quota store type alias for convenience.
pub type QuotaStoreImpl = GenericQuotaStore<Arc<HybridStore>>;
@ -79,6 +82,10 @@ pub struct AppState {
/// When GroupCommitBuffer successfully flushes a batch, it signals this
/// Notify so IngestWorker can immediately refresh and process new records.
pub flush_notify: Arc<Notify>,
/// Scan cache for Aphoria scan history (feature-gated).
#[cfg(feature = "aphoria")]
pub scan_cache: ScanCache,
}
impl AppState {
@ -141,6 +148,8 @@ impl AppState {
circuit_breaker_store,
api_key_store,
flush_notify,
#[cfg(feature = "aphoria")]
scan_cache: ScanCache::new(),
}
}