Initial commit: Elydora Open Source — self-hosted audit platform for AI agents
Full self-hosted port of the Elydora tamper-evident audit protocol: - Hono API server with PostgreSQL, Redis, MinIO adapters - Ed25519 signed operations, chain hashing, Merkle epoch rollups - PBKDF2 password hashing, HMAC-SHA256 JWT, 5-role RBAC - Next.js management console - Node.js, Python, and Go SDKs - Docker Compose for local deployment - Helm chart for Kubernetes - Comprehensive security hardening and audit fixes applied Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
15547a8c35
commit
e390e68594
207 files changed
+32790
-63
No files matched your search
@@ -0,0 +1,263 @@
|
||||
import type {
|
||||
Agent,
|
||||
RegisterAgentRequest,
|
||||
RegisterAgentResponse,
|
||||
GetAgentResponse,
|
||||
ListAgentsResponse,
|
||||
FreezeAgentRequest,
|
||||
UnfreezeAgentRequest,
|
||||
RevokeAgentRequest,
|
||||
UpdateAgentRequest,
|
||||
SubmitOperationRequest,
|
||||
SubmitOperationResponse,
|
||||
GetOperationResponse,
|
||||
VerifyOperationResponse,
|
||||
AuditQueryRequest,
|
||||
AuditQueryResponse,
|
||||
GetEpochResponse,
|
||||
CreateExportRequest,
|
||||
CreateExportResponse,
|
||||
GetExportResponse,
|
||||
AuthLoginResponse,
|
||||
AuthRegisterResponse,
|
||||
AuthMeResponse,
|
||||
AuthRefreshResponse,
|
||||
IssueTokenResponse,
|
||||
JWKSResponse,
|
||||
ErrorResponse,
|
||||
} from '@elydora/shared';
|
||||
|
||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:8787';
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly code: string,
|
||||
public readonly requestId: string,
|
||||
message: string,
|
||||
public readonly details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
function getAuthToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('elydora_token');
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const token = getAuthToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(options.headers as Record<string, string> ?? {}),
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorBody: ErrorResponse | null = null;
|
||||
try {
|
||||
errorBody = await response.json() as ErrorResponse;
|
||||
} catch {
|
||||
// Response body is not JSON
|
||||
}
|
||||
|
||||
if (errorBody?.error) {
|
||||
throw new ApiError(
|
||||
response.status,
|
||||
errorBody.error.code,
|
||||
errorBody.error.request_id,
|
||||
errorBody.error.message,
|
||||
errorBody.error.details,
|
||||
);
|
||||
}
|
||||
|
||||
throw new ApiError(
|
||||
response.status,
|
||||
'INTERNAL_ERROR',
|
||||
'unknown',
|
||||
`Request failed with status ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
agents: {
|
||||
list(): Promise<ListAgentsResponse> {
|
||||
return request<ListAgentsResponse>('/v1/agents');
|
||||
},
|
||||
|
||||
register(body: RegisterAgentRequest): Promise<RegisterAgentResponse> {
|
||||
return request<RegisterAgentResponse>('/v1/agents/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
|
||||
get(agentId: string): Promise<GetAgentResponse> {
|
||||
return request<GetAgentResponse>(`/v1/agents/${encodeURIComponent(agentId)}`);
|
||||
},
|
||||
|
||||
update(agentId: string, body: UpdateAgentRequest): Promise<{ agent: Agent }> {
|
||||
return request<{ agent: Agent }>(`/v1/agents/${encodeURIComponent(agentId)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
|
||||
freeze(agentId: string, reason: string): Promise<void> {
|
||||
const body: FreezeAgentRequest = { reason };
|
||||
return request<void>(`/v1/agents/${encodeURIComponent(agentId)}/freeze`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
|
||||
unfreeze(agentId: string, reason: string): Promise<void> {
|
||||
const body: UnfreezeAgentRequest = { reason };
|
||||
return request<void>(`/v1/agents/${encodeURIComponent(agentId)}/unfreeze`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
|
||||
revokeKey(agentId: string, kid: string, reason: string): Promise<void> {
|
||||
const body: RevokeAgentRequest = { kid, reason };
|
||||
return request<void>(`/v1/agents/${encodeURIComponent(agentId)}/revoke`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
|
||||
delete(agentId: string): Promise<void> {
|
||||
return request<void>(`/v1/agents/${encodeURIComponent(agentId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
operations: {
|
||||
submit(eor: SubmitOperationRequest): Promise<SubmitOperationResponse> {
|
||||
return request<SubmitOperationResponse>('/v1/operations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(eor),
|
||||
});
|
||||
},
|
||||
|
||||
get(operationId: string): Promise<GetOperationResponse> {
|
||||
return request<GetOperationResponse>(
|
||||
`/v1/operations/${encodeURIComponent(operationId)}`,
|
||||
);
|
||||
},
|
||||
|
||||
verify(operationId: string): Promise<VerifyOperationResponse> {
|
||||
return request<VerifyOperationResponse>(
|
||||
`/v1/operations/${encodeURIComponent(operationId)}/verify`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
audit: {
|
||||
query(params: AuditQueryRequest): Promise<AuditQueryResponse> {
|
||||
return request<AuditQueryResponse>(`/v1/audit/query`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
epochs: {
|
||||
list(): Promise<{ epochs: Array<{ epoch_id: string; org_id: string; start_time: number; end_time: number; root_hash: string; leaf_count: number; r2_epoch_key: string; created_at: number }> }> {
|
||||
return request(`/v1/epochs`);
|
||||
},
|
||||
|
||||
get(epochId: string): Promise<GetEpochResponse> {
|
||||
return request<GetEpochResponse>(`/v1/epochs/${encodeURIComponent(epochId)}`);
|
||||
},
|
||||
},
|
||||
|
||||
exports: {
|
||||
create(body: CreateExportRequest): Promise<CreateExportResponse> {
|
||||
return request<CreateExportResponse>('/v1/exports', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
|
||||
get(exportId: string): Promise<GetExportResponse> {
|
||||
return request<GetExportResponse>(`/v1/exports/${encodeURIComponent(exportId)}`);
|
||||
},
|
||||
|
||||
list(): Promise<{ exports: Array<import('@elydora/shared').Export> }> {
|
||||
return request(`/v1/exports`);
|
||||
},
|
||||
|
||||
async download(exportId: string): Promise<Blob> {
|
||||
const token = getAuthToken();
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/v1/exports/${encodeURIComponent(exportId)}/download`,
|
||||
{
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, 'DOWNLOAD_ERROR', 'unknown', `Download failed with status ${response.status}`);
|
||||
}
|
||||
return response.blob();
|
||||
},
|
||||
},
|
||||
|
||||
jwks: {
|
||||
get(): Promise<JWKSResponse> {
|
||||
return request<JWKSResponse>('/.well-known/elydora/jwks.json');
|
||||
},
|
||||
},
|
||||
|
||||
auth: {
|
||||
login(email: string, password: string): Promise<AuthLoginResponse> {
|
||||
return request<AuthLoginResponse>('/v1/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
},
|
||||
|
||||
register(email: string, password: string, displayName: string, orgName: string): Promise<AuthRegisterResponse> {
|
||||
return request<AuthRegisterResponse>('/v1/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, display_name: displayName, org_name: orgName }),
|
||||
});
|
||||
},
|
||||
|
||||
me(): Promise<AuthMeResponse> {
|
||||
return request<AuthMeResponse>('/v1/auth/me');
|
||||
},
|
||||
|
||||
refresh(): Promise<AuthRefreshResponse> {
|
||||
return request<AuthRefreshResponse>('/v1/auth/refresh', {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
|
||||
issueToken(ttlSeconds: number | null): Promise<IssueTokenResponse> {
|
||||
return request<IssueTokenResponse>('/v1/auth/token', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ttl_seconds: ttlSeconds }),
|
||||
});
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export { ApiError };
|
||||
export type { ErrorResponse };
|
||||
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { RbacRole } from '@elydora/shared';
|
||||
|
||||
interface AuthUser {
|
||||
sub: string;
|
||||
org_id: string;
|
||||
role: RbacRole;
|
||||
display_name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (token: string, user?: { display_name?: string; email?: string }) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
function decodeJWTPayload(token: string): AuthUser | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
// base64url decode
|
||||
let base64 = parts[1]!.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (base64.length % 4 !== 0) base64 += '=';
|
||||
const json = atob(base64);
|
||||
const payload = JSON.parse(json);
|
||||
// Check expiration
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
if (typeof payload.exp === 'number' && payload.exp < nowSeconds) return null;
|
||||
if (!payload.sub || !payload.org_id || !payload.role) return null;
|
||||
return { sub: payload.sub, org_id: payload.org_id, role: payload.role };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('elydora_token');
|
||||
if (stored) {
|
||||
const decoded = decodeJWTPayload(stored);
|
||||
if (decoded) {
|
||||
setToken(stored);
|
||||
// Try to fetch full user profile from localStorage
|
||||
const displayName = localStorage.getItem('elydora_display_name');
|
||||
const email = localStorage.getItem('elydora_email');
|
||||
if (displayName) decoded.display_name = displayName;
|
||||
if (email) decoded.email = email;
|
||||
setUser({ ...decoded });
|
||||
} else {
|
||||
// Token expired or invalid, clear it
|
||||
localStorage.removeItem('elydora_token');
|
||||
localStorage.removeItem('elydora_display_name');
|
||||
localStorage.removeItem('elydora_email');
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = useCallback((newToken: string, userInfo?: { display_name?: string; email?: string }) => {
|
||||
localStorage.setItem('elydora_token', newToken);
|
||||
const decoded = decodeJWTPayload(newToken);
|
||||
if (decoded) {
|
||||
if (userInfo?.display_name) {
|
||||
decoded.display_name = userInfo.display_name;
|
||||
localStorage.setItem('elydora_display_name', userInfo.display_name);
|
||||
}
|
||||
if (userInfo?.email) {
|
||||
decoded.email = userInfo.email;
|
||||
localStorage.setItem('elydora_email', userInfo.email);
|
||||
}
|
||||
setUser(decoded);
|
||||
}
|
||||
setToken(newToken);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem('elydora_token');
|
||||
localStorage.removeItem('elydora_display_name');
|
||||
localStorage.removeItem('elydora_email');
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, token, isAuthenticated: !!user, isLoading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
import useSWR from 'swr';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { api } from './api';
|
||||
import type {
|
||||
AuditQueryRequest,
|
||||
GetAgentResponse,
|
||||
ListAgentsResponse,
|
||||
GetOperationResponse,
|
||||
GetEpochResponse,
|
||||
GetExportResponse,
|
||||
AuditQueryResponse,
|
||||
JWKSResponse,
|
||||
} from '@elydora/shared';
|
||||
|
||||
export function useAgent(agentId: string | undefined) {
|
||||
return useSWR<GetAgentResponse>(
|
||||
agentId ? `/agents/${agentId}` : null,
|
||||
agentId ? () => api.agents.get(agentId) : null,
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
}
|
||||
|
||||
export function useAgentsList() {
|
||||
return useSWR<ListAgentsResponse>(
|
||||
'/agents',
|
||||
() => api.agents.list(),
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
}
|
||||
|
||||
export function useOperation(operationId: string | undefined) {
|
||||
return useSWR<GetOperationResponse>(
|
||||
operationId ? `/operations/${operationId}` : null,
|
||||
operationId ? () => api.operations.get(operationId) : null,
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
}
|
||||
|
||||
export function useAudit(params: AuditQueryRequest) {
|
||||
return useSWR<AuditQueryResponse>(
|
||||
['/audit', params],
|
||||
() => api.audit.query(params),
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
}
|
||||
|
||||
export function useEpoch(epochId: string | undefined) {
|
||||
return useSWR<GetEpochResponse>(
|
||||
epochId ? `/epochs/${epochId}` : null,
|
||||
epochId ? () => api.epochs.get(epochId) : null,
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
}
|
||||
|
||||
export function useEpochs() {
|
||||
return useSWR<{ epochs: Array<{ epoch_id: string; org_id: string; start_time: number; end_time: number; root_hash: string; leaf_count: number; r2_epoch_key: string; created_at: number }> }>(
|
||||
'/epochs',
|
||||
() => api.epochs.list(),
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
}
|
||||
|
||||
export function useExport(exportId: string | undefined) {
|
||||
return useSWR<GetExportResponse>(
|
||||
exportId ? `/exports/${exportId}` : null,
|
||||
exportId ? () => api.exports.get(exportId) : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
refreshInterval: (data) => {
|
||||
if (data?.export?.status === 'queued' || data?.export?.status === 'running') {
|
||||
return 3000;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function useExports() {
|
||||
return useSWR<{ exports: Array<import('@elydora/shared').Export> }>(
|
||||
'/exports',
|
||||
() => api.exports.list(),
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
}
|
||||
|
||||
export function useJWKS() {
|
||||
return useSWR<JWKSResponse>(
|
||||
'/jwks',
|
||||
() => api.jwks.get(),
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
}
|
||||
|
||||
export function useFadeIn() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry?.isIntersecting) {
|
||||
setIsVisible(true);
|
||||
observer.unobserve(element);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return { ref, isVisible };
|
||||
}
|
||||
|
||||
export function useClipboard() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copy = useCallback(async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { copied, copy };
|
||||
}
|
||||
|
||||
export function formatTimestamp(ms: number): string {
|
||||
return new Date(ms).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatRelativeTime(ms: number): string {
|
||||
const now = Date.now();
|
||||
const diff = now - ms;
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) return `${days}d ago`;
|
||||
if (hours > 0) return `${hours}h ago`;
|
||||
if (minutes > 0) return `${minutes}m ago`;
|
||||
return `${seconds}s ago`;
|
||||
}
|
||||
|
||||
export function truncateHash(hash: string, length = 8): string {
|
||||
if (hash.length <= length * 2) return hash;
|
||||
return `${hash.slice(0, length)}...${hash.slice(-length)}`;
|
||||
}
|
||||
Reference in new issue
Block a user