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,5 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
.git
|
||||
*.md
|
||||
@@ -0,0 +1,17 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.next/
|
||||
out/
|
||||
.wrangler/
|
||||
.open-next/
|
||||
.dev.vars
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.turbo/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,25 @@
|
||||
# Multi-stage build
|
||||
# Stage 1: Install deps and build
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ARG NEXT_PUBLIC_API_BASE_URL=http://localhost:8787
|
||||
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Production
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,124 @@
|
||||
# Elydora Console
|
||||
|
||||
Web dashboard for the Elydora Responsibility Protocol. Provides agent management, operation inspection, epoch visualization, audit log querying, and compliance export tools.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework:** Next.js 16.1 (App Router)
|
||||
- **Language:** TypeScript 5.7
|
||||
- **UI:** React 19, Tailwind CSS 4
|
||||
- **Data Fetching:** SWR 2.3
|
||||
- **Deployment:** Cloudflare Pages (static export)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── layout.tsx # Root layout with AppShell
|
||||
│ ├── page.tsx # Dashboard — stats, recent operations, quick actions
|
||||
│ ├── globals.css # Theme variables, global styles
|
||||
│ ├── agents/
|
||||
│ │ ├── page.tsx # Agent list, search, registration
|
||||
│ │ └── [agent_id]/page.tsx # Agent details, keys, freeze/unfreeze
|
||||
│ ├── operations/
|
||||
│ │ ├── page.tsx # Operations log with filtering
|
||||
│ │ └── [operation_id]/page.tsx # Operation details and verification
|
||||
│ ├── epochs/
|
||||
│ │ ├── page.tsx # Epoch list with root hashes
|
||||
│ │ └── [epoch_id]/page.tsx # Epoch details, Merkle proof visualization
|
||||
│ ├── exports/
|
||||
│ │ └── page.tsx # Create and download compliance exports
|
||||
│ ├── audit/
|
||||
│ │ └── page.tsx # Audit log query builder
|
||||
│ ├── login/page.tsx # Login form
|
||||
│ ├── register/page.tsx # Registration form
|
||||
│ └── jwks/page.tsx # Public key display
|
||||
├── components/
|
||||
│ ├── AppShell.tsx # Main layout wrapper with sidebar
|
||||
│ ├── AgentDetailClient.tsx # Agent info and key management
|
||||
│ ├── AgentRegistrationForm.tsx # Register new agent form
|
||||
│ ├── OperationDetailClient.tsx # Operation inspection and verification
|
||||
│ ├── OperationDetailCard.tsx # Operation data display
|
||||
│ ├── EpochDetailClient.tsx # Epoch proof and verification
|
||||
│ ├── ChainVisualization.tsx # Merkle chain visualization
|
||||
│ ├── VerificationChecklist.tsx # Proof verification UI
|
||||
│ └── ui/
|
||||
│ ├── PageHeader.tsx # Page title and breadcrumbs
|
||||
│ ├── DataTable.tsx # Sortable, paginated table
|
||||
│ ├── Modal.tsx # Dialog component
|
||||
│ ├── SearchInput.tsx # Search/filter input
|
||||
│ ├── StatusBadge.tsx # Status indicator
|
||||
│ ├── CopyButton.tsx # Copy-to-clipboard
|
||||
│ └── Sidebar.tsx # Navigation sidebar
|
||||
├── lib/
|
||||
│ ├── api.ts # Typed REST client for all endpoints
|
||||
│ ├── auth.tsx # Auth context and hooks
|
||||
│ └── hooks.ts # SWR data-fetching hooks
|
||||
└── shared/ # Shared types (@elydora/shared)
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Environment
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|----------|---------|---------|
|
||||
| `NEXT_PUBLIC_API_BASE_URL` | Backend API URL | `http://localhost:8787` |
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Runs at `http://localhost:3000` with Turbopack.
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Produces a static export in `out/`. A post-build script removes stale `_redirects` files.
|
||||
|
||||
### Deployment
|
||||
|
||||
```bash
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
Deploys the `out/` directory to Cloudflare Pages.
|
||||
|
||||
### Type Checking
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
## Pages
|
||||
|
||||
| Path | Description |
|
||||
|------|-------------|
|
||||
| `/` | Dashboard with stats, recent operations, and quick actions |
|
||||
| `/agents` | List, search, and register agents |
|
||||
| `/agents/[agent_id]` | Agent details, key management, freeze/unfreeze |
|
||||
| `/operations` | Browse operations with agent and type filters |
|
||||
| `/operations/[operation_id]` | Operation details, chain hash verification |
|
||||
| `/epochs` | List epochs with root hashes and leaf counts |
|
||||
| `/epochs/[epoch_id]` | Epoch details with Merkle proof visualization |
|
||||
| `/exports` | Create and download compliance exports (JSON/PDF) |
|
||||
| `/audit` | Query builder for audit log with time range and agent filters |
|
||||
| `/login` | Login |
|
||||
| `/register` | Register account and organization |
|
||||
| `/jwks` | Display server public keys in JWK format |
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+1794
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "elydora-console",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "^25.8.13",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"next": "^16.1.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^16.5.4",
|
||||
"swr": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"@types/node": "25.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"postcss": "^8.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="7" fill="#121212"/>
|
||||
<path d="M9 8H23V12H13V14H19V18H13V20H23V24H9Z" fill="#EAEAE5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 225 B |
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import AgentDetailClient from '@/components/AgentDetailClient';
|
||||
|
||||
export default function AgentDetailShell() {
|
||||
const [agentId, setAgentId] = useState('');
|
||||
useEffect(() => {
|
||||
setAgentId(window.location.pathname.split('/')[2] ?? '');
|
||||
}, []);
|
||||
if (!agentId) return null;
|
||||
return <AgentDetailClient agentId={agentId} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import AgentDetailShell from './client';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return [{ agent_id: 'detail' }];
|
||||
}
|
||||
|
||||
export default function AgentDetailPage() {
|
||||
return <AgentDetailShell />;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useState, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAgentsList, useAudit, formatRelativeTime } from '@/lib/hooks';
|
||||
import { api } from '@/lib/api';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import type { Column } from '@/components/ui/DataTable';
|
||||
import SearchInput from '@/components/ui/SearchInput';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import AgentRegistrationForm from '@/components/AgentRegistrationForm';
|
||||
|
||||
interface AgentRow {
|
||||
agent_id: string;
|
||||
display_name: string;
|
||||
status: string;
|
||||
created_at: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Fetches the real total_count for a single agent via the audit API. */
|
||||
function AgentOpCount({ agentId }: { agentId: string }) {
|
||||
const { data, isLoading } = useAudit({ agent_id: agentId });
|
||||
if (isLoading) return <span className="font-mono text-[13px] text-ink-dim">—</span>;
|
||||
return <span className="font-mono text-[13px] text-ink-dim">{data?.total_count ?? 0}</span>;
|
||||
}
|
||||
|
||||
/** Fetches the latest operation timestamp for a single agent via the audit API. */
|
||||
function AgentLatestActivity({ agentId, fallback }: { agentId: string; fallback: number }) {
|
||||
const { data, isLoading } = useAudit({ agent_id: agentId });
|
||||
if (isLoading) return <span className="font-mono text-[12px] text-ink-dim">—</span>;
|
||||
const latest = data?.operations?.[0]?.issued_at ?? fallback;
|
||||
return <span className="font-mono text-[12px] text-ink-dim">{formatRelativeTime(latest)}</span>;
|
||||
}
|
||||
|
||||
function AgentsContent() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [search, setSearch] = useState('');
|
||||
const [showRegister, setShowRegister] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<AgentRow | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
const { data: agentsData, isLoading: agentsLoading, error: agentsError, mutate } = useAgentsList();
|
||||
|
||||
const agents: AgentRow[] = useMemo(() => {
|
||||
if (!agentsData?.agents) return [];
|
||||
return agentsData.agents.map((agent) => ({
|
||||
agent_id: agent.agent_id,
|
||||
display_name: agent.display_name,
|
||||
status: agent.status,
|
||||
created_at: agent.created_at,
|
||||
}));
|
||||
}, [agentsData]);
|
||||
|
||||
const filteredAgents = useMemo(() => {
|
||||
if (!search.trim()) return agents;
|
||||
const q = search.toLowerCase();
|
||||
return agents.filter(
|
||||
(a) => a.agent_id.toLowerCase().includes(q) || a.display_name.toLowerCase().includes(q),
|
||||
);
|
||||
}, [agents, search]);
|
||||
|
||||
const columns: Column<AgentRow>[] = [
|
||||
{
|
||||
key: 'agent_id',
|
||||
label: t('agents.colAgent'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<div>
|
||||
<span className="font-mono text-[13px] text-ink font-medium">{row.agent_id}</span>
|
||||
{row.display_name && row.display_name !== row.agent_id && (
|
||||
<span className="ml-2 font-mono text-[11px] text-ink-dim">{row.display_name}</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: t('agents.colStatus'),
|
||||
sortable: true,
|
||||
width: '100px',
|
||||
render: (row) => (
|
||||
<span
|
||||
className={`font-mono text-[11px] uppercase tracking-wider ${
|
||||
row.status === 'active'
|
||||
? 'text-ink'
|
||||
: row.status === 'frozen'
|
||||
? 'text-amber-600'
|
||||
: 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{row.status}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'operation_count',
|
||||
label: t('agents.colOperations'),
|
||||
width: '120px',
|
||||
render: (row) => <AgentOpCount agentId={row.agent_id} />,
|
||||
},
|
||||
{
|
||||
key: 'latest_activity',
|
||||
label: t('agents.colLatestActivity'),
|
||||
render: (row) => <AgentLatestActivity agentId={row.agent_id} fallback={row.created_at} />,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
width: '80px',
|
||||
render: (row) => (
|
||||
<button
|
||||
className="btn-ghost text-[10px] py-1 px-2 text-red-600 hover:text-red-700"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTarget(row);
|
||||
}}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: AgentRow) => {
|
||||
router.push(`/agents/${row.agent_id}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const handleRegisterSuccess = useCallback(() => {
|
||||
setShowRegister(false);
|
||||
mutate();
|
||||
}, [mutate]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await api.agents.delete(deleteTarget.agent_id);
|
||||
setDeleteTarget(null);
|
||||
mutate();
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : t('agents.failedToDelete'));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}, [deleteTarget, mutate]);
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('agents.title')}
|
||||
subtitle={t('agents.subtitle')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('agents.title') },
|
||||
]}
|
||||
actions={
|
||||
<button className="btn-brutalist" onClick={() => setShowRegister(true)}>
|
||||
{t('agents.registerAgent')}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{agentsError && (
|
||||
<div className="mb-6 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('agents.failedToLoad')} {agentsError instanceof Error ? agentsError.message : t('common.unknownError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={t('agents.searchPlaceholder')}
|
||||
className="max-w-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredAgents}
|
||||
keyExtractor={(row) => row.agent_id}
|
||||
onRowClick={handleRowClick}
|
||||
isLoading={agentsLoading}
|
||||
emptyMessage={t('agents.emptyMessage')}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={showRegister}
|
||||
onClose={() => setShowRegister(false)}
|
||||
title={t('agents.registerAgent')}
|
||||
width="max-w-2xl"
|
||||
>
|
||||
<AgentRegistrationForm
|
||||
onSuccess={handleRegisterSuccess}
|
||||
onCancel={() => setShowRegister(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={deleteTarget !== null}
|
||||
onClose={() => { setDeleteTarget(null); setDeleteError(null); }}
|
||||
title={t('agents.deleteAgent')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="font-sans text-sm text-ink-dim">
|
||||
{t('agents.deleteConfirmPre')}<strong className="text-ink font-mono text-[13px]">{deleteTarget?.agent_id}</strong>{t('agents.deleteConfirmPost')}
|
||||
</p>
|
||||
{deleteError && (
|
||||
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{deleteError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => { setDeleteTarget(null); setDeleteError(null); }}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="btn-brutalist bg-red-600 text-white border-red-700 hover:bg-red-700"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? t('agents.deleting') : t('agents.deleteAgent')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentsPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="fade-in"><div className="border border-border p-6"><div className="space-y-3"><div className="skeleton h-10 w-full max-w-md" /><div className="skeleton h-6 w-full" /><div className="skeleton h-6 w-full" /><div className="skeleton h-6 w-3/4" /></div></div></div>}>
|
||||
<AgentsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAudit, formatTimestamp, formatRelativeTime } from '@/lib/hooks';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import type { Column } from '@/components/ui/DataTable';
|
||||
import type { Operation, AuditQueryRequest } from '@elydora/shared';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function AuditPage() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [agentId, setAgentId] = useState('');
|
||||
const [operationType, setOperationType] = useState('');
|
||||
const [startTime, setStartTime] = useState('');
|
||||
const [endTime, setEndTime] = useState('');
|
||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||
const [limit] = useState(50);
|
||||
|
||||
const queryParams: AuditQueryRequest = {
|
||||
agent_id: agentId || undefined,
|
||||
operation_type: operationType || undefined,
|
||||
start_time: startTime ? new Date(startTime).getTime() : undefined,
|
||||
end_time: endTime ? new Date(endTime).getTime() : undefined,
|
||||
cursor,
|
||||
limit,
|
||||
};
|
||||
|
||||
const { data, isLoading, error } = useAudit(queryParams);
|
||||
const operations = data?.operations ?? [];
|
||||
|
||||
const columns: Column<Operation & Record<string, unknown>>[] = [
|
||||
{
|
||||
key: 'operation_id',
|
||||
label: t('audit.colOperationId'),
|
||||
render: (row) => (
|
||||
<Link
|
||||
href={`/operations/${row.operation_id}`}
|
||||
className="font-mono text-[13px] text-ink hover:underline no-underline"
|
||||
>
|
||||
{row.operation_id.slice(0, 18)}...
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'agent_id',
|
||||
label: t('audit.colAgent'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<Link
|
||||
href={`/agents/${row.agent_id}`}
|
||||
className="font-mono text-[13px] text-ink-dim hover:text-ink no-underline transition-colors"
|
||||
>
|
||||
{row.agent_id}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'operation_type',
|
||||
label: t('audit.colType'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider px-2 py-1 bg-surface border border-border">
|
||||
{row.operation_type}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'issued_at',
|
||||
label: t('audit.colIssuedAt'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[12px] text-ink-dim" title={formatTimestamp(row.issued_at)}>
|
||||
{formatRelativeTime(row.issued_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'seq_no',
|
||||
label: t('audit.colSeq'),
|
||||
sortable: true,
|
||||
width: '80px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink-dim">#{row.seq_no}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'chain_hash',
|
||||
label: t('audit.colChainHash'),
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[11px] text-ink-dim">
|
||||
{row.chain_hash.slice(0, 12)}...
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setAgentId('');
|
||||
setOperationType('');
|
||||
setStartTime('');
|
||||
setEndTime('');
|
||||
setCursor(undefined);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('audit.title')}
|
||||
subtitle={t('audit.subtitle')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('audit.title') },
|
||||
]}
|
||||
actions={
|
||||
<Link href="/exports" className="btn-ghost inline-block no-underline">
|
||||
{t('audit.exportData')}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('audit.failedToLoad')} {error instanceof Error ? error.message : t('common.unknownError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Query Builder */}
|
||||
<div className="border border-border bg-surface p-4 sm:p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="section-label">{t('audit.queryBuilder')}</h3>
|
||||
<button className="btn-ghost text-[10px] py-1 px-3" onClick={handleClearFilters}>
|
||||
{t('audit.clearFilters')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="font-mono text-[10px] text-ink-dim uppercase tracking-wider block mb-1">
|
||||
{t('audit.agentId')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={agentId}
|
||||
onChange={(e) => {
|
||||
setAgentId(e.target.value);
|
||||
setCursor(undefined);
|
||||
}}
|
||||
placeholder={t('audit.filterByAgent')}
|
||||
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-mono text-[10px] text-ink-dim uppercase tracking-wider block mb-1">
|
||||
{t('audit.operationType')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={operationType}
|
||||
onChange={(e) => {
|
||||
setOperationType(e.target.value);
|
||||
setCursor(undefined);
|
||||
}}
|
||||
placeholder={t('audit.filterByType')}
|
||||
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-mono text-[10px] text-ink-dim uppercase tracking-wider block mb-1">
|
||||
{t('audit.startTime')}
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startTime}
|
||||
onChange={(e) => {
|
||||
setStartTime(e.target.value);
|
||||
setCursor(undefined);
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink focus:outline-none focus:border-ink transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-mono text-[10px] text-ink-dim uppercase tracking-wider block mb-1">
|
||||
{t('audit.endTime')}
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={endTime}
|
||||
onChange={(e) => {
|
||||
setEndTime(e.target.value);
|
||||
setCursor(undefined);
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink focus:outline-none focus:border-ink transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
<div className="mt-4 pt-4 border-t border-border flex items-center gap-6">
|
||||
<span className="font-mono text-[11px] text-ink-dim">
|
||||
{t('audit.showing', { shown: operations.length, total: data.total_count })}
|
||||
</span>
|
||||
{data.cursor && (
|
||||
<span className="font-mono text-[11px] text-ink-dim">
|
||||
{t('audit.moreResults')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results Table */}
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={operations as (Operation & Record<string, unknown>)[]}
|
||||
keyExtractor={(row) => row.operation_id as string}
|
||||
onRowClick={(row) => { router.push(`/operations/${row.operation_id}`); }}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={t('audit.emptyMessage')}
|
||||
pagination={
|
||||
data?.cursor
|
||||
? {
|
||||
cursor: data.cursor,
|
||||
hasMore: true,
|
||||
onLoadMore: () => setCursor(data.cursor),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import EpochDetailClient from '@/components/EpochDetailClient';
|
||||
|
||||
export default function EpochDetailShell() {
|
||||
const [epochId, setEpochId] = useState('');
|
||||
useEffect(() => {
|
||||
setEpochId(window.location.pathname.split('/')[2] ?? '');
|
||||
}, []);
|
||||
if (!epochId) return null;
|
||||
return <EpochDetailClient epochId={epochId} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import EpochDetailShell from './client';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return [{ epoch_id: 'detail' }];
|
||||
}
|
||||
|
||||
export default function EpochDetailPage() {
|
||||
return <EpochDetailShell />;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useEpochs, formatTimestamp, truncateHash } from '@/lib/hooks';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import type { Column } from '@/components/ui/DataTable';
|
||||
import CopyButton from '@/components/ui/CopyButton';
|
||||
|
||||
interface EpochRow {
|
||||
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;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function EpochsContent() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { data, isLoading, error } = useEpochs();
|
||||
const epochs = (data?.epochs ?? []) as EpochRow[];
|
||||
|
||||
const columns: Column<EpochRow>[] = [
|
||||
{
|
||||
key: 'epoch_id',
|
||||
label: t('epochs.colEpochId'),
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink font-medium">
|
||||
{row.epoch_id.slice(0, 16)}...
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'start_time',
|
||||
label: t('epochs.colTimeRange'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<div className="font-mono text-[11px] text-ink-dim">
|
||||
<div>{formatTimestamp(row.start_time)}</div>
|
||||
<div className="text-[10px]">to {formatTimestamp(row.end_time)}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'leaf_count',
|
||||
label: t('epochs.colLeaves'),
|
||||
sortable: true,
|
||||
width: '100px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink">{row.leaf_count}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'root_hash',
|
||||
label: t('epochs.colRootHash'),
|
||||
render: (row) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[12px] text-ink-dim">
|
||||
{truncateHash(row.root_hash, 10)}
|
||||
</span>
|
||||
<CopyButton text={row.root_hash} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: t('epochs.colCreated'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[12px] text-ink-dim">
|
||||
{formatTimestamp(row.created_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: EpochRow) => {
|
||||
router.push(`/epochs/${row.epoch_id}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('epochs.title')}
|
||||
subtitle={t('epochs.subtitle')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('epochs.title') },
|
||||
]}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('epochs.failedToLoad')} {error instanceof Error ? error.message : t('common.unknownError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={epochs}
|
||||
keyExtractor={(row) => row.epoch_id}
|
||||
onRowClick={handleRowClick}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={t('epochs.emptyMessage')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EpochsPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="fade-in"><div className="border border-border p-6"><div className="space-y-3"><div className="skeleton h-10 w-full max-w-md" /><div className="skeleton h-6 w-full" /><div className="skeleton h-6 w-full" /><div className="skeleton h-6 w-3/4" /></div></div></div>}>
|
||||
<EpochsContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useExports, formatTimestamp } from '@/lib/hooks';
|
||||
import { api } from '@/lib/api';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import type { Column } from '@/components/ui/DataTable';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import type { Export, CreateExportRequest, ExportStatus } from '@elydora/shared';
|
||||
|
||||
interface ExportRow extends Export {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export default function ExportsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data, isLoading, error, mutate } = useExports();
|
||||
const exports = (data?.exports ?? []) as ExportRow[];
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [startTime, setStartTime] = useState('');
|
||||
const [endTime, setEndTime] = useState('');
|
||||
const [agentId, setAgentId] = useState('');
|
||||
const [operationType, setOperationType] = useState('');
|
||||
const [format, setFormat] = useState<'json' | 'pdf'>('json');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!startTime || !endTime) {
|
||||
setCreateError(t('exports.startEndRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const body: CreateExportRequest = {
|
||||
start_time: new Date(startTime).getTime(),
|
||||
end_time: new Date(endTime).getTime(),
|
||||
agent_id: agentId || undefined,
|
||||
operation_type: operationType || undefined,
|
||||
format,
|
||||
};
|
||||
await api.exports.create(body);
|
||||
setShowCreate(false);
|
||||
setStartTime('');
|
||||
setEndTime('');
|
||||
setAgentId('');
|
||||
setOperationType('');
|
||||
mutate();
|
||||
} catch (err) {
|
||||
setCreateError(err instanceof Error ? err.message : t('exports.failedToCreate'));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}, [startTime, endTime, agentId, operationType, format, mutate]);
|
||||
|
||||
const handleDownload = useCallback(async (exportId: string, queryParams: string) => {
|
||||
try {
|
||||
let ext = '';
|
||||
try {
|
||||
const params = JSON.parse(queryParams);
|
||||
ext = params.format === 'pdf' ? '.pdf' : '.json';
|
||||
} catch { ext = ''; }
|
||||
const blob = await api.exports.download(exportId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `export-${exportId}${ext}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// Download error is non-critical
|
||||
}
|
||||
}, []);
|
||||
|
||||
const columns: Column<ExportRow>[] = [
|
||||
{
|
||||
key: 'export_id',
|
||||
label: t('exports.colExportId'),
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink">
|
||||
{row.export_id.slice(0, 16)}...
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: t('exports.colStatus'),
|
||||
width: '120px',
|
||||
render: (row) => <StatusBadge status={row.status as ExportStatus} size="sm" />,
|
||||
},
|
||||
{
|
||||
key: 'query_params',
|
||||
label: t('exports.colFilters'),
|
||||
render: (row) => {
|
||||
let params: Record<string, unknown> = {};
|
||||
try {
|
||||
params = JSON.parse(row.query_params);
|
||||
} catch {
|
||||
// Not valid JSON
|
||||
}
|
||||
const entries = Object.entries(params).filter(([, v]) => v != null);
|
||||
if (entries.length === 0) {
|
||||
return <span className="font-mono text-[11px] text-ink-dim">{t('common.allData')}</span>;
|
||||
}
|
||||
return (
|
||||
<span className="font-mono text-[11px] text-ink-dim">
|
||||
{entries.map(([k, v]) => `${k}=${v}`).join(', ')}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: t('exports.colCreated'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[12px] text-ink-dim">
|
||||
{formatTimestamp(row.created_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'completed_at',
|
||||
label: t('exports.colCompleted'),
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[12px] text-ink-dim">
|
||||
{row.completed_at ? formatTimestamp(row.completed_at) : '\u2014'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
width: '100px',
|
||||
render: (row) =>
|
||||
row.status === 'done' ? (
|
||||
<button
|
||||
className="btn-ghost text-[10px] py-1 px-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownload(row.export_id, row.query_params);
|
||||
}}
|
||||
>
|
||||
{t('common.download')}
|
||||
</button>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('exports.title')}
|
||||
subtitle={t('exports.subtitle')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('exports.title') },
|
||||
]}
|
||||
actions={
|
||||
<button className="btn-brutalist" onClick={() => setShowCreate(true)}>
|
||||
{t('exports.createExport')}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('exports.failedToLoad')} {error instanceof Error ? error.message : t('common.unknownError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={exports}
|
||||
keyExtractor={(row) => row.export_id}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={t('exports.emptyMessage')}
|
||||
/>
|
||||
|
||||
{/* Create Export Modal */}
|
||||
<Modal
|
||||
isOpen={showCreate}
|
||||
onClose={() => {
|
||||
setShowCreate(false);
|
||||
setCreateError(null);
|
||||
}}
|
||||
title={t('exports.createExport')}
|
||||
width="max-w-xl"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{createError && (
|
||||
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{createError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="section-label block mb-1.5">{t('exports.startTime')}</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startTime}
|
||||
onChange={(e) => setStartTime(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink focus:outline-none focus:border-ink transition-colors"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="section-label block mb-1.5">{t('exports.endTime')}</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={endTime}
|
||||
onChange={(e) => setEndTime(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink focus:outline-none focus:border-ink transition-colors"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="section-label block mb-1.5">{t('exports.agentIdOptional')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={agentId}
|
||||
onChange={(e) => setAgentId(e.target.value)}
|
||||
placeholder={t('exports.filterByAgent')}
|
||||
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="section-label block mb-1.5">{t('exports.operationTypeOptional')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={operationType}
|
||||
onChange={(e) => setOperationType(e.target.value)}
|
||||
placeholder={t('exports.filterByOperationType')}
|
||||
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="section-label block mb-1.5">{t('exports.format')}</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="format"
|
||||
value="json"
|
||||
checked={format === 'json'}
|
||||
onChange={() => setFormat('json')}
|
||||
className="accent-ink"
|
||||
/>
|
||||
<span className="font-mono text-[13px] text-ink">JSON</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="format"
|
||||
value="pdf"
|
||||
checked={format === 'pdf'}
|
||||
onChange={() => setFormat('pdf')}
|
||||
className="accent-ink"
|
||||
/>
|
||||
<span className="font-mono text-[13px] text-ink">PDF</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => {
|
||||
setShowCreate(false);
|
||||
setCreateError(null);
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button className="btn-brutalist" onClick={handleCreate} disabled={creating}>
|
||||
{creating ? t('exports.creating') : t('exports.createExport')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
export default function GlobalError({
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<div style={{ padding: '2rem', fontFamily: 'sans-serif' }}>
|
||||
<h2>Something went wrong</h2>
|
||||
<button onClick={() => reset()}>Try again</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@200;300;400;500;600;700;800&family=JetBrains+Mono:wght@300;400;500&family=Noto+Sans+SC:wght@300;400;500;600;700;800&display=swap');
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--bg-color: #EAEAE5;
|
||||
--surface-color: #F2F2EE;
|
||||
--ink-color: #121212;
|
||||
--ink-dim: #666666;
|
||||
--border-color: #C0C0BA;
|
||||
--grid-line: rgba(18, 18, 18, 0.08);
|
||||
--white: #FFFFFF;
|
||||
--sidebar-width: 260px;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-bg: var(--bg-color);
|
||||
--color-surface: var(--surface-color);
|
||||
--color-ink: var(--ink-color);
|
||||
--color-ink-dim: var(--ink-dim);
|
||||
--color-border: var(--border-color);
|
||||
--color-grid-line: var(--grid-line);
|
||||
--font-sans: 'Manrope', 'Noto Sans SC', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Noto Sans SC', monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--ink-color);
|
||||
font-family: 'Manrope', sans-serif;
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
:lang(zh) body,
|
||||
:lang(zh-CN) body,
|
||||
:lang(zh-TW) body {
|
||||
font-family: 'Noto Sans SC', 'Manrope', sans-serif;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-color);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--ink-color);
|
||||
}
|
||||
|
||||
/* Grid background */
|
||||
.grid-bg {
|
||||
background-image:
|
||||
linear-gradient(var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
|
||||
/* Crosshair markers */
|
||||
.crosshair {
|
||||
position: absolute;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
.crosshair::before, .crosshair::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background-color: var(--ink-color);
|
||||
}
|
||||
.crosshair::before { width: 1px; height: 100%; left: 5px; }
|
||||
.crosshair::after { height: 1px; width: 100%; top: 5px; }
|
||||
|
||||
.ch-tl { top: -12px; left: -12px; }
|
||||
.ch-tr { top: -12px; right: -12px; }
|
||||
.ch-bl { bottom: -12px; left: -12px; }
|
||||
.ch-br { bottom: -12px; right: -12px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.crosshair { display: none; }
|
||||
}
|
||||
|
||||
/* Display text */
|
||||
.display-text {
|
||||
font-size: clamp(2.5rem, 5vw, 4.5rem);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
/* Console display - smaller variant */
|
||||
.console-display {
|
||||
font-size: clamp(1.5rem, 3vw, 2.5rem);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
/* Navigation item */
|
||||
.nav-item {
|
||||
position: relative;
|
||||
padding-left: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.nav-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: var(--ink-color);
|
||||
transform: translateY(-50%) scale(0);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
.nav-item:hover {
|
||||
padding-left: 1.5rem;
|
||||
color: var(--ink-color);
|
||||
}
|
||||
.nav-item:hover::before {
|
||||
transform: translateY(-50%) scale(1);
|
||||
}
|
||||
|
||||
/* Sidebar navigation item */
|
||||
.sidebar-nav-item {
|
||||
position: relative;
|
||||
padding: 8px 16px;
|
||||
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgba(234, 234, 229, 0.6);
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.sidebar-nav-item:hover {
|
||||
color: #EAEAE5;
|
||||
background: rgba(234, 234, 229, 0.05);
|
||||
}
|
||||
.sidebar-nav-item.active {
|
||||
color: #EAEAE5;
|
||||
background: rgba(234, 234, 229, 0.08);
|
||||
}
|
||||
.sidebar-nav-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 2px;
|
||||
background: #EAEAE5;
|
||||
}
|
||||
|
||||
/* Marquee */
|
||||
.marquee-container {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.marquee-content {
|
||||
display: inline-block;
|
||||
animation: marquee 40s linear infinite;
|
||||
}
|
||||
@keyframes marquee {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-50%); }
|
||||
}
|
||||
|
||||
/* Brutalist button */
|
||||
.btn-brutalist {
|
||||
background: var(--ink-color);
|
||||
color: var(--bg-color);
|
||||
border: 1px solid var(--ink-color);
|
||||
transition: all 0.2s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
padding: 10px 24px;
|
||||
}
|
||||
.btn-brutalist:hover {
|
||||
background: transparent;
|
||||
color: var(--ink-color);
|
||||
}
|
||||
.btn-brutalist:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-brutalist:disabled:hover {
|
||||
background: var(--ink-color);
|
||||
color: var(--bg-color);
|
||||
}
|
||||
|
||||
/* Ghost button */
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--ink-color);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.2s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
cursor: pointer;
|
||||
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
padding: 10px 24px;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--bg-color);
|
||||
border-color: var(--ink-color);
|
||||
}
|
||||
.btn-ghost:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Data row */
|
||||
.data-row {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.data-row:hover {
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Feature card */
|
||||
.feature-card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.feature-card:hover {
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Stat card */
|
||||
.stat-card {
|
||||
background: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.stat-card:hover {
|
||||
background: #FFFFFF;
|
||||
}
|
||||
|
||||
/* Loading bar animation */
|
||||
@keyframes load {
|
||||
0% { transform: scaleX(0); }
|
||||
50% { transform: scaleX(0.7); }
|
||||
100% { transform: scaleX(0); transform-origin: right; }
|
||||
}
|
||||
|
||||
/* Fade in animation */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.fade-in {
|
||||
animation: fadeIn 0.6s ease forwards;
|
||||
}
|
||||
|
||||
/* Stagger children */
|
||||
.stagger-children > * {
|
||||
opacity: 0;
|
||||
animation: fadeIn 0.5s ease forwards;
|
||||
}
|
||||
.stagger-children > *:nth-child(1) { animation-delay: 0.1s; }
|
||||
.stagger-children > *:nth-child(2) { animation-delay: 0.2s; }
|
||||
.stagger-children > *:nth-child(3) { animation-delay: 0.3s; }
|
||||
.stagger-children > *:nth-child(4) { animation-delay: 0.4s; }
|
||||
.stagger-children > *:nth-child(5) { animation-delay: 0.5s; }
|
||||
.stagger-children > *:nth-child(6) { animation-delay: 0.6s; }
|
||||
|
||||
/* Skeleton loading */
|
||||
@keyframes shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, var(--border-color) 25%, var(--surface-color) 50%, var(--border-color) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Pulse dot animation */
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
.pulse-dot {
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Modal overlay */
|
||||
.modal-overlay {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Section label */
|
||||
.section-label {
|
||||
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
/* Table header */
|
||||
.table-header {
|
||||
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
/* Monospace data */
|
||||
.mono-data {
|
||||
font-family: 'JetBrains Mono', 'Noto Sans SC', monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Mobile-friendly table cells */
|
||||
@media (max-width: 640px) {
|
||||
.stat-card {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Checkbox/check icon */
|
||||
.check-pass {
|
||||
color: #16a34a;
|
||||
}
|
||||
.check-fail {
|
||||
color: #dc2626;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { useJWKS } from '@/lib/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import CopyButton from '@/components/ui/CopyButton';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import type { Column } from '@/components/ui/DataTable';
|
||||
import type { JWK } from '@elydora/shared';
|
||||
|
||||
interface JWKRow extends JWK {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export default function JWKSPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data: jwksData, isLoading: jwksLoading, error: jwksError } = useJWKS();
|
||||
const keys = (jwksData?.keys ?? []) as JWKRow[];
|
||||
|
||||
const jwkColumns: Column<JWKRow>[] = [
|
||||
{
|
||||
key: 'kid',
|
||||
label: t('jwks.colKeyId'),
|
||||
render: (row) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[13px] text-ink font-medium">{row.kid}</span>
|
||||
<CopyButton text={row.kid} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'kty',
|
||||
label: t('jwks.colKeyType'),
|
||||
width: '100px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider px-2 py-1 bg-surface border border-border">
|
||||
{row.kty}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'alg',
|
||||
label: t('jwks.colAlgorithm'),
|
||||
width: '120px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink">{row.alg}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'crv',
|
||||
label: t('jwks.colCurve'),
|
||||
width: '100px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink-dim">{row.crv ?? '\u2014'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'use',
|
||||
label: t('jwks.colUse'),
|
||||
width: '80px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[11px] text-ink-dim uppercase">{row.use}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'x',
|
||||
label: t('jwks.colPublicKey'),
|
||||
render: (row) =>
|
||||
row.x ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[11px] text-ink-dim">
|
||||
{row.x.slice(0, 20)}...
|
||||
</span>
|
||||
<CopyButton text={row.x} />
|
||||
</div>
|
||||
) : (
|
||||
<span className="font-mono text-[11px] text-ink-dim">{'\u2014'}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('jwks.title')}
|
||||
subtitle={t('jwks.subtitle')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('jwks.title') },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="section-label">{t('jwks.publicKeys')}</div>
|
||||
{jwksData && (
|
||||
<CopyButton
|
||||
text={JSON.stringify(jwksData, null, 2)}
|
||||
label={t('common.copyJwks')}
|
||||
className="text-[11px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{jwksError && (
|
||||
<div className="mb-4 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('jwks.failedToLoad')} {jwksError instanceof Error ? jwksError.message : t('common.unknownError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
columns={jwkColumns}
|
||||
data={keys}
|
||||
keyExtractor={(row) => row.kid}
|
||||
isLoading={jwksLoading}
|
||||
emptyMessage={t('jwks.emptyMessage')}
|
||||
/>
|
||||
|
||||
<div className="mt-3 font-mono text-[11px] text-ink-dim">
|
||||
{t('jwks.endpoint')} <span className="text-ink">/.well-known/elydora/jwks.json</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
import AppShell from '@/components/AppShell';
|
||||
import I18nProvider from '@/components/I18nProvider';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Elydora Console',
|
||||
description: 'Enterprise AI accountability and compliance console',
|
||||
icons: { icon: '/favicon.svg' },
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="bg-bg text-ink font-sans antialiased">
|
||||
<I18nProvider>
|
||||
<AppShell>{children}</AppShell>
|
||||
</I18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { login } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await api.auth.login(email, password);
|
||||
login(result.token, { display_name: result.user.display_name, email: result.user.email });
|
||||
router.push('/');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('login.loginFailed'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg flex items-center justify-center px-6">
|
||||
<div className="w-full max-w-[400px]">
|
||||
{/* Logo */}
|
||||
<div className="mb-12 text-center">
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<div className="w-10 h-10 border border-ink flex items-center justify-center">
|
||||
<span className="font-mono text-lg font-bold text-ink">E</span>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-sans text-base font-semibold tracking-wide text-ink">ELYDORA</div>
|
||||
<div className="font-mono text-[10px] text-ink-dim tracking-widest uppercase">Console</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="border border-border bg-surface p-6 sm:p-8">
|
||||
<h1 className="font-sans text-xl font-semibold tracking-tight text-ink mb-6">{t('login.signIn')}</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 border border-red-300 bg-red-50 text-red-700 text-sm font-mono">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block font-mono text-[11px] uppercase tracking-wider text-ink-dim mb-1.5">{t('login.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2.5 border border-border bg-bg font-mono text-sm text-ink focus:outline-none focus:border-ink transition-colors"
|
||||
placeholder={t('login.emailPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-mono text-[11px] uppercase tracking-wider text-ink-dim mb-1.5">{t('login.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2.5 border border-border bg-bg font-mono text-sm text-ink focus:outline-none focus:border-ink transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full btn-brutalist py-3 text-xs uppercase font-bold tracking-wider disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? t('login.signingIn') : t('login.submitSignIn')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<span className="font-mono text-xs text-ink-dim">{t('login.noAccount')}</span>
|
||||
<Link href="/register" className="font-mono text-xs text-ink underline hover:no-underline">
|
||||
{t('login.createOne')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import OperationDetailClient from '@/components/OperationDetailClient';
|
||||
|
||||
export default function OperationDetailShell() {
|
||||
const [operationId, setOperationId] = useState('');
|
||||
useEffect(() => {
|
||||
setOperationId(window.location.pathname.split('/')[2] ?? '');
|
||||
}, []);
|
||||
if (!operationId) return null;
|
||||
return <OperationDetailClient operationId={operationId} />;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import OperationDetailShell from './client';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return [{ operation_id: 'detail' }];
|
||||
}
|
||||
|
||||
export default function OperationDetailPage() {
|
||||
return <OperationDetailShell />;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useState, useCallback, useMemo } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAudit, formatRelativeTime } from '@/lib/hooks';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import type { Column } from '@/components/ui/DataTable';
|
||||
import SearchInput from '@/components/ui/SearchInput';
|
||||
import type { Operation } from '@elydora/shared';
|
||||
|
||||
function OperationsContent() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const initialAgentId = searchParams.get('agent_id') ?? '';
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [agentFilter, setAgentFilter] = useState(initialAgentId);
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
const [cursor, setCursor] = useState<string | undefined>(undefined);
|
||||
|
||||
const { data, isLoading, error } = useAudit({
|
||||
agent_id: agentFilter || undefined,
|
||||
operation_type: typeFilter || undefined,
|
||||
cursor,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const operations = data?.operations ?? [];
|
||||
|
||||
const filteredOps = useMemo(() => {
|
||||
if (!search.trim()) return operations;
|
||||
const q = search.toLowerCase();
|
||||
return operations.filter(
|
||||
(op) =>
|
||||
op.operation_id.toLowerCase().includes(q) ||
|
||||
op.agent_id.toLowerCase().includes(q) ||
|
||||
op.operation_type.toLowerCase().includes(q),
|
||||
);
|
||||
}, [operations, search]);
|
||||
|
||||
const columns: Column<Operation & Record<string, unknown>>[] = [
|
||||
{
|
||||
key: 'operation_id',
|
||||
label: t('operations.colOperationId'),
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink">
|
||||
{row.operation_id.slice(0, 20)}...
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'agent_id',
|
||||
label: t('operations.colAgent'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink-dim">{row.agent_id}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'operation_type',
|
||||
label: t('operations.colType'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider px-2 py-1 bg-surface border border-border">
|
||||
{row.operation_type}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'issued_at',
|
||||
label: t('operations.colIssuedAt'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[12px] text-ink-dim">
|
||||
{formatRelativeTime(row.issued_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'seq_no',
|
||||
label: t('operations.colSeq'),
|
||||
sortable: true,
|
||||
width: '80px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink-dim">#{row.seq_no}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: Operation & Record<string, unknown>) => {
|
||||
router.push(`/operations/${row.operation_id}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
<div className="mb-6 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('operations.failedToLoad')} {error instanceof Error ? error.message : t('common.unknownError')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="mb-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={t('operations.searchPlaceholder')}
|
||||
className="sm:col-span-2"
|
||||
/>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] text-ink-dim uppercase tracking-wider block mb-1">
|
||||
{t('operations.agentIdFilter')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={agentFilter}
|
||||
onChange={(e) => setAgentFilter(e.target.value)}
|
||||
placeholder={t('operations.filterByAgent')}
|
||||
className="w-full px-3 py-2.5 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-mono text-[10px] text-ink-dim uppercase tracking-wider block mb-1">
|
||||
{t('operations.typeFilter')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
placeholder={t('operations.filterByType')}
|
||||
className="w-full px-3 py-2.5 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredOps as (Operation & Record<string, unknown>)[]}
|
||||
keyExtractor={(row) => row.operation_id as string}
|
||||
onRowClick={handleRowClick}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={t('operations.emptyMessage')}
|
||||
pagination={
|
||||
data?.cursor
|
||||
? {
|
||||
cursor: data.cursor,
|
||||
hasMore: true,
|
||||
onLoadMore: () => setCursor(data.cursor),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OperationsPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('operations.title')}
|
||||
subtitle={t('operations.subtitle')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('operations.title') },
|
||||
]}
|
||||
/>
|
||||
<Suspense fallback={
|
||||
<div className="border border-border p-6">
|
||||
<div className="space-y-3">
|
||||
<div className="skeleton h-10 w-full max-w-md" />
|
||||
<div className="skeleton h-6 w-full" />
|
||||
<div className="skeleton h-6 w-full" />
|
||||
<div className="skeleton h-6 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<OperationsContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
'use client';
|
||||
|
||||
import { useAgentsList, useAudit, useEpochs, useExports, formatTimestamp, formatRelativeTime } from '@/lib/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import Link from 'next/link';
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
subtitle,
|
||||
isLoading,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
subtitle?: string;
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="stat-card">
|
||||
<div className="section-label mb-3">{label}</div>
|
||||
{isLoading ? (
|
||||
<div className="skeleton h-8 w-20 mb-1" />
|
||||
) : (
|
||||
<div className="font-sans text-3xl font-semibold tracking-tight text-ink leading-none">
|
||||
{value}
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<div className="font-mono text-[11px] text-ink-dim mt-2">{subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data: agentsData, isLoading: agentsLoading } = useAgentsList();
|
||||
const { data: auditData, isLoading: auditLoading, error: auditError } = useAudit({});
|
||||
const { data: epochsData, isLoading: epochsLoading } = useEpochs();
|
||||
const { data: exportsData, isLoading: exportsLoading } = useExports();
|
||||
|
||||
const agentsList = agentsData?.agents ?? [];
|
||||
const operations = auditData?.operations ?? [];
|
||||
const recentOps = operations.slice(0, 10);
|
||||
const totalOps = auditData?.total_count ?? 0;
|
||||
const epochs = epochsData?.epochs ?? [];
|
||||
const exports = exportsData?.exports ?? [];
|
||||
const pendingExports = exports.filter((e) => e.status === 'queued' || e.status === 'running');
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('dashboard.title')}
|
||||
subtitle={t('dashboard.subtitle')}
|
||||
/>
|
||||
|
||||
{auditError && (
|
||||
<div className="mb-6 px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('dashboard.failedToLoad')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8 stagger-children">
|
||||
<StatCard
|
||||
label={t('dashboard.totalAgents')}
|
||||
value={agentsList.length}
|
||||
subtitle={t('dashboard.registeredAgents')}
|
||||
isLoading={agentsLoading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.totalOperations')}
|
||||
value={totalOps}
|
||||
subtitle={t('dashboard.allRecordedOps')}
|
||||
isLoading={auditLoading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.activeEpochs')}
|
||||
value={epochs.length}
|
||||
subtitle={t('dashboard.merkleRollups')}
|
||||
isLoading={epochsLoading}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('dashboard.pendingExports')}
|
||||
value={pendingExports.length}
|
||||
subtitle={t('dashboard.queuedOrRunning')}
|
||||
isLoading={exportsLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mb-8">
|
||||
<div className="section-label mb-3">{t('dashboard.quickActions')}</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link href="/agents" className="btn-brutalist inline-block no-underline">
|
||||
{t('dashboard.registerAgent')}
|
||||
</Link>
|
||||
<Link href="/operations" className="btn-ghost inline-block no-underline">
|
||||
{t('dashboard.viewOperations')}
|
||||
</Link>
|
||||
<Link href="/exports" className="btn-ghost inline-block no-underline">
|
||||
{t('dashboard.createExport')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Operations */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="section-label">{t('dashboard.recentOperations')}</div>
|
||||
<Link
|
||||
href="/operations"
|
||||
className="font-mono text-[11px] text-ink-dim hover:text-ink transition-colors no-underline uppercase tracking-wider"
|
||||
>
|
||||
{t('common.viewAll')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="border border-border overflow-hidden">
|
||||
{auditLoading ? (
|
||||
<div className="p-4 space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex gap-4">
|
||||
<div className="skeleton h-4 w-40" />
|
||||
<div className="skeleton h-4 w-24" />
|
||||
<div className="skeleton h-4 w-32" />
|
||||
<div className="skeleton h-4 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : recentOps.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="font-mono text-sm text-ink-dim">
|
||||
{t('dashboard.noOperations')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface">
|
||||
<th className="table-header text-left px-4 py-3">{t('dashboard.colOperationId')}</th>
|
||||
<th className="table-header text-left px-4 py-3">{t('dashboard.colAgent')}</th>
|
||||
<th className="table-header text-left px-4 py-3">{t('dashboard.colType')}</th>
|
||||
<th className="table-header text-left px-4 py-3">{t('dashboard.colIssued')}</th>
|
||||
<th className="table-header text-left px-4 py-3">{t('dashboard.colSeq')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentOps.map((op) => (
|
||||
<tr key={op.operation_id} className="data-row border-b border-border last:border-b-0">
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={`/operations/${op.operation_id}`}
|
||||
className="font-mono text-[13px] text-ink hover:underline no-underline"
|
||||
>
|
||||
{op.operation_id.slice(0, 16)}...
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[13px] text-ink-dim">
|
||||
{op.agent_id}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider px-2 py-1 bg-surface border border-border">
|
||||
{op.operation_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[12px] text-ink-dim">
|
||||
{formatRelativeTime(op.issued_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-[13px] text-ink-dim">
|
||||
#{op.seq_no}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Status Summary */}
|
||||
<div>
|
||||
<div className="section-label mb-3">{t('dashboard.agentSummary')}</div>
|
||||
<div className="border border-border bg-surface p-4 sm:p-6">
|
||||
{agentsLoading ? (
|
||||
<div className="flex gap-8">
|
||||
<div className="skeleton h-4 w-32" />
|
||||
<div className="skeleton h-4 w-32" />
|
||||
<div className="skeleton h-4 w-32" />
|
||||
</div>
|
||||
) : agentsList.length === 0 ? (
|
||||
<p className="font-mono text-sm text-ink-dim">
|
||||
{t('dashboard.noAgentsRegistered')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-8">
|
||||
<div>
|
||||
<div className="font-mono text-[11px] text-ink-dim uppercase tracking-wider mb-1">
|
||||
{t('dashboard.totalAgents')}
|
||||
</div>
|
||||
<div className="font-sans text-xl font-semibold text-ink">
|
||||
{agentsList.length}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-mono text-[11px] text-ink-dim uppercase tracking-wider mb-1">
|
||||
{t('dashboard.totalOperations')}
|
||||
</div>
|
||||
<div className="font-sans text-xl font-semibold text-ink">
|
||||
{totalOps}
|
||||
</div>
|
||||
</div>
|
||||
{operations.length > 0 && operations[0] != null && (
|
||||
<div>
|
||||
<div className="font-mono text-[11px] text-ink-dim uppercase tracking-wider mb-1">
|
||||
{t('dashboard.latestActivity')}
|
||||
</div>
|
||||
<div className="font-mono text-sm text-ink">
|
||||
{formatTimestamp(operations[0].issued_at)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { login } = useAuth();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [orgName, setOrgName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await api.auth.register(email, password, displayName, orgName);
|
||||
login(result.token, { display_name: result.user.display_name, email: result.user.email });
|
||||
router.push('/');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('register.registrationFailed'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-bg flex items-center justify-center px-6">
|
||||
<div className="w-full max-w-[400px]">
|
||||
{/* Logo */}
|
||||
<div className="mb-12 text-center">
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<div className="w-10 h-10 border border-ink flex items-center justify-center">
|
||||
<span className="font-mono text-lg font-bold text-ink">E</span>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-sans text-base font-semibold tracking-wide text-ink">ELYDORA</div>
|
||||
<div className="font-mono text-[10px] text-ink-dim tracking-widest uppercase">Console</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="border border-border bg-surface p-8">
|
||||
<h1 className="font-sans text-xl font-semibold tracking-tight text-ink mb-6">{t('register.createAccount')}</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 border border-red-300 bg-red-50 text-red-700 text-sm font-mono">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block font-mono text-[11px] uppercase tracking-wider text-ink-dim mb-1.5">{t('register.orgName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={orgName}
|
||||
onChange={(e) => setOrgName(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2.5 border border-border bg-bg font-mono text-sm text-ink focus:outline-none focus:border-ink transition-colors"
|
||||
placeholder={t('register.orgPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-mono text-[11px] uppercase tracking-wider text-ink-dim mb-1.5">{t('register.displayName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2.5 border border-border bg-bg font-mono text-sm text-ink focus:outline-none focus:border-ink transition-colors"
|
||||
placeholder={t('register.displayNamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-mono text-[11px] uppercase tracking-wider text-ink-dim mb-1.5">{t('register.email')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2.5 border border-border bg-bg font-mono text-sm text-ink focus:outline-none focus:border-ink transition-colors"
|
||||
placeholder={t('register.emailPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block font-mono text-[11px] uppercase tracking-wider text-ink-dim mb-1.5">{t('register.password')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3 py-2.5 border border-border bg-bg font-mono text-sm text-ink focus:outline-none focus:border-ink transition-colors"
|
||||
placeholder={t('register.passwordPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full btn-brutalist py-3 text-xs uppercase font-bold tracking-wider disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? t('register.creatingAccount') : t('register.submitCreateAccount')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<span className="font-mono text-xs text-ink-dim">{t('register.hasAccount')}</span>
|
||||
<Link href="/login" className="font-mono text-xs text-ink underline hover:no-underline">
|
||||
{t('register.signIn')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import CopyButton from '@/components/ui/CopyButton';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('settings.title')}
|
||||
subtitle={t('settings.subtitle')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('settings.title') },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* User Information */}
|
||||
<div className="mb-8">
|
||||
<div className="section-label mb-3">{t('settings.user')}</div>
|
||||
<div className="border border-border bg-surface p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="font-mono text-[10px] text-ink-dim uppercase tracking-wider mb-1">
|
||||
{t('settings.displayName')}
|
||||
</div>
|
||||
<div className="font-sans text-sm text-ink">
|
||||
{user?.display_name ?? '\u2014'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-mono text-[10px] text-ink-dim uppercase tracking-wider mb-1">
|
||||
{t('settings.email')}
|
||||
</div>
|
||||
<div className="font-sans text-sm text-ink">
|
||||
{user?.email ?? '\u2014'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-mono text-[10px] text-ink-dim uppercase tracking-wider mb-1">
|
||||
{t('settings.subject')}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[13px] text-ink">{user?.sub ?? '\u2014'}</span>
|
||||
{user?.sub && <CopyButton text={user.sub} />}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-mono text-[10px] text-ink-dim uppercase tracking-wider mb-1">
|
||||
{t('settings.role')}
|
||||
</div>
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider px-2 py-1 bg-bg border border-border text-ink">
|
||||
{user?.role ?? '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organization */}
|
||||
<div className="mb-8">
|
||||
<div className="section-label mb-3">{t('settings.organization')}</div>
|
||||
<div className="border border-border bg-surface p-6">
|
||||
<div>
|
||||
<div className="font-mono text-[10px] text-ink-dim uppercase tracking-wider mb-1">
|
||||
{t('settings.organizationId')}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[13px] text-ink">{user?.org_id ?? '\u2014'}</span>
|
||||
{user?.org_id && <CopyButton text={user.org_id} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sign Out */}
|
||||
<div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="px-4 py-2 font-mono text-[12px] uppercase tracking-wider border border-border bg-surface text-ink hover:bg-bg transition-colors"
|
||||
>
|
||||
{t('common.signOut')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useAgent, useAudit, formatTimestamp } from '@/lib/hooks';
|
||||
import { api } from '@/lib/api';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import StatusBadge from '@/components/ui/StatusBadge';
|
||||
import CopyButton from '@/components/ui/CopyButton';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import type { Column } from '@/components/ui/DataTable';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import Link from 'next/link';
|
||||
import type { AgentKey, Operation } from '@elydora/shared';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const HOOKS_INTEGRATIONS = new Set([
|
||||
'claudecode', 'cursor', 'gemini', 'kirocli', 'kiroide', 'opencode', 'copilot', 'letta',
|
||||
]);
|
||||
|
||||
export default function AgentDetailClient({ agentId }: { agentId: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: agentData, isLoading: agentLoading, error: agentError, mutate: mutateAgent } = useAgent(agentId);
|
||||
const { data: opsData, isLoading: opsLoading } = useAudit({ agent_id: agentId });
|
||||
|
||||
const [showFreezeModal, setShowFreezeModal] = useState(false);
|
||||
const [freezeReason, setFreezeReason] = useState('');
|
||||
const [freezing, setFreezing] = useState(false);
|
||||
const [freezeError, setFreezeError] = useState<string | null>(null);
|
||||
|
||||
const [showUnfreezeModal, setShowUnfreezeModal] = useState(false);
|
||||
const [unfreezeReason, setUnfreezeReason] = useState('');
|
||||
const [unfreezing, setUnfreezing] = useState(false);
|
||||
const [unfreezeError, setUnfreezeError] = useState<string | null>(null);
|
||||
|
||||
const [revokeKeyId, setRevokeKeyId] = useState<string | null>(null);
|
||||
const [revokeReason, setRevokeReason] = useState('');
|
||||
const [revoking, setRevoking] = useState(false);
|
||||
const [revokeError, setRevokeError] = useState<string | null>(null);
|
||||
|
||||
const agent = agentData?.agent;
|
||||
const supportsFreeze = !agent?.integration_type || HOOKS_INTEGRATIONS.has(agent.integration_type);
|
||||
const keys = agentData?.keys ?? [];
|
||||
const operations = opsData?.operations ?? [];
|
||||
|
||||
const handleFreeze = useCallback(async () => {
|
||||
if (!freezeReason.trim()) {
|
||||
setFreezeError(t('agentDetail.reasonRequired'));
|
||||
return;
|
||||
}
|
||||
setFreezing(true);
|
||||
setFreezeError(null);
|
||||
try {
|
||||
await api.agents.freeze(agentId, freezeReason.trim());
|
||||
setShowFreezeModal(false);
|
||||
setFreezeReason('');
|
||||
mutateAgent();
|
||||
} catch (err) {
|
||||
setFreezeError(err instanceof Error ? err.message : t('agentDetail.failedToFreeze'));
|
||||
} finally {
|
||||
setFreezing(false);
|
||||
}
|
||||
}, [agentId, freezeReason, mutateAgent, t]);
|
||||
|
||||
const handleUnfreeze = useCallback(async () => {
|
||||
if (!unfreezeReason.trim()) {
|
||||
setUnfreezeError(t('agentDetail.reasonRequired'));
|
||||
return;
|
||||
}
|
||||
setUnfreezing(true);
|
||||
setUnfreezeError(null);
|
||||
try {
|
||||
await api.agents.unfreeze(agentId, unfreezeReason.trim());
|
||||
setShowUnfreezeModal(false);
|
||||
setUnfreezeReason('');
|
||||
mutateAgent();
|
||||
} catch (err) {
|
||||
setUnfreezeError(err instanceof Error ? err.message : t('agentDetail.failedToUnfreeze'));
|
||||
} finally {
|
||||
setUnfreezing(false);
|
||||
}
|
||||
}, [agentId, unfreezeReason, mutateAgent, t]);
|
||||
|
||||
const handleRevokeKey = useCallback(async () => {
|
||||
if (!revokeKeyId || !revokeReason.trim()) {
|
||||
setRevokeError(t('agentDetail.reasonRequired'));
|
||||
return;
|
||||
}
|
||||
setRevoking(true);
|
||||
setRevokeError(null);
|
||||
try {
|
||||
await api.agents.revokeKey(agentId, revokeKeyId, revokeReason.trim());
|
||||
setRevokeKeyId(null);
|
||||
setRevokeReason('');
|
||||
mutateAgent();
|
||||
} catch (err) {
|
||||
setRevokeError(err instanceof Error ? err.message : t('agentDetail.failedToRevoke'));
|
||||
} finally {
|
||||
setRevoking(false);
|
||||
}
|
||||
}, [agentId, revokeKeyId, revokeReason, mutateAgent, t]);
|
||||
|
||||
const keyColumns: Column<AgentKey & Record<string, unknown>>[] = [
|
||||
{
|
||||
key: 'kid',
|
||||
label: t('agentDetail.colKeyId'),
|
||||
render: (row) => (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[13px] text-ink">{row.kid}</span>
|
||||
<CopyButton text={row.kid} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'algorithm',
|
||||
label: t('agentDetail.colAlgorithm'),
|
||||
width: '120px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider px-2 py-1 bg-surface border border-border">
|
||||
{row.algorithm}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: t('agentDetail.colStatus'),
|
||||
width: '120px',
|
||||
render: (row) => <StatusBadge status={row.status} size="sm" />,
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: t('agentDetail.colCreated'),
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[12px] text-ink-dim">
|
||||
{formatTimestamp(row.created_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
width: '80px',
|
||||
render: (row) =>
|
||||
row.status === 'active' ? (
|
||||
<button
|
||||
className="btn-ghost text-[10px] py-1 px-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRevokeKeyId(row.kid);
|
||||
}}
|
||||
>
|
||||
{t('agentDetail.revoke')}
|
||||
</button>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
const opColumns: Column<Operation & Record<string, unknown>>[] = [
|
||||
{
|
||||
key: 'operation_id',
|
||||
label: t('operationCard.operationId'),
|
||||
render: (row) => (
|
||||
<Link
|
||||
href={`/operations/${row.operation_id}`}
|
||||
className="font-mono text-[13px] text-ink hover:underline no-underline"
|
||||
>
|
||||
{row.operation_id.slice(0, 16)}...
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'operation_type',
|
||||
label: t('operationCard.type'),
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider px-2 py-1 bg-surface border border-border">
|
||||
{row.operation_type}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'issued_at',
|
||||
label: t('operationCard.issuedAt'),
|
||||
sortable: true,
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[12px] text-ink-dim">
|
||||
{formatTimestamp(row.issued_at)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'seq_no',
|
||||
label: t('operationCard.seqNo'),
|
||||
width: '80px',
|
||||
render: (row) => (
|
||||
<span className="font-mono text-[13px] text-ink-dim">#{row.seq_no}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (agentLoading) {
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('agentDetail.title')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('common.agents'), href: '/agents' },
|
||||
{ label: agentId },
|
||||
]}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div className="border border-border bg-surface p-6">
|
||||
<div className="space-y-3">
|
||||
<div className="skeleton h-6 w-64" />
|
||||
<div className="skeleton h-4 w-48" />
|
||||
<div className="skeleton h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (agentError) {
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('agentDetail.title')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('common.agents'), href: '/agents' },
|
||||
{ label: agentId },
|
||||
]}
|
||||
/>
|
||||
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('agentDetail.failedToLoad')} {agentError instanceof Error ? agentError.message : t('agentDetail.mayNotExist')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={agent?.display_name ?? agentId}
|
||||
subtitle={agent?.display_name ? agentId : undefined}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('common.agents'), href: '/agents' },
|
||||
{ label: agent?.display_name ?? agentId },
|
||||
]}
|
||||
actions={
|
||||
supportsFreeze && agent?.status === 'active' ? (
|
||||
<button
|
||||
className="btn-brutalist"
|
||||
onClick={() => setShowFreezeModal(true)}
|
||||
>
|
||||
{t('agentDetail.freezeAgent')}
|
||||
</button>
|
||||
) : supportsFreeze && agent?.status === 'frozen' ? (
|
||||
<button
|
||||
className="btn-brutalist"
|
||||
onClick={() => setShowUnfreezeModal(true)}
|
||||
>
|
||||
{t('agentDetail.unfreezeAgent')}
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Agent Info Card */}
|
||||
<div className="border border-border bg-surface p-4 sm:p-6 mb-6 relative">
|
||||
<span className="crosshair ch-tl" />
|
||||
<span className="crosshair ch-tr" />
|
||||
<span className="crosshair ch-bl" />
|
||||
<span className="crosshair ch-br" />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('agentDetail.agentId')}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[14px] text-ink break-all">{agent?.agent_id}</span>
|
||||
{agent?.agent_id && <CopyButton text={agent.agent_id} />}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('agentDetail.status')}</div>
|
||||
{agent?.status && <StatusBadge status={agent.status} />}
|
||||
</div>
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('agentDetail.organization')}</div>
|
||||
<span className="font-mono text-[13px] text-ink break-all">{agent?.org_id}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('agentDetail.displayName')}</div>
|
||||
<span className="font-sans text-sm text-ink">
|
||||
{agent?.display_name ?? '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('agentDetail.responsibleEntity')}</div>
|
||||
<span className="font-sans text-sm text-ink">
|
||||
{agent?.responsible_entity ?? '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('agentDetail.created')}</div>
|
||||
<span className="font-mono text-[12px] text-ink-dim">
|
||||
{agent?.created_at ? formatTimestamp(agent.created_at) : '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keys */}
|
||||
<div className="mb-6">
|
||||
<div className="section-label mb-3">{t('agentDetail.keys')} ({keys.length})</div>
|
||||
<DataTable
|
||||
columns={keyColumns}
|
||||
data={keys as (AgentKey & Record<string, unknown>)[]}
|
||||
keyExtractor={(row) => row.kid}
|
||||
emptyMessage={t('agentDetail.noKeys')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent Operations */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="section-label">
|
||||
{t('agentDetail.operations')} ({opsData?.total_count ?? 0})
|
||||
</div>
|
||||
<Link
|
||||
href={`/operations?agent_id=${agentId}`}
|
||||
className="font-mono text-[11px] text-ink-dim hover:text-ink transition-colors no-underline uppercase tracking-wider"
|
||||
>
|
||||
{t('common.viewAll')}
|
||||
</Link>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={opColumns}
|
||||
data={(operations.slice(0, 10)) as (Operation & Record<string, unknown>)[]}
|
||||
keyExtractor={(row) => row.operation_id}
|
||||
isLoading={opsLoading}
|
||||
emptyMessage={t('agentDetail.noOperations')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Freeze Modal */}
|
||||
<Modal
|
||||
isOpen={showFreezeModal}
|
||||
onClose={() => {
|
||||
setShowFreezeModal(false);
|
||||
setFreezeError(null);
|
||||
}}
|
||||
title={t('agentDetail.freezeAgent')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="font-sans text-sm text-ink-dim">
|
||||
{t('agentDetail.freezeDescPre')}<strong className="text-ink">{agentId}</strong>{t('agentDetail.freezeDescPost')}
|
||||
</p>
|
||||
{freezeError && (
|
||||
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{freezeError}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="section-label block mb-1.5">{t('agentDetail.reason')}</label>
|
||||
<textarea
|
||||
value={freezeReason}
|
||||
onChange={(e) => setFreezeReason(e.target.value)}
|
||||
placeholder={t('agentDetail.freezePlaceholder')}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => {
|
||||
setShowFreezeModal(false);
|
||||
setFreezeError(null);
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button className="btn-brutalist" onClick={handleFreeze} disabled={freezing}>
|
||||
{freezing ? t('agentDetail.freezing') : t('agentDetail.confirmFreeze')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Unfreeze Modal */}
|
||||
<Modal
|
||||
isOpen={showUnfreezeModal}
|
||||
onClose={() => {
|
||||
setShowUnfreezeModal(false);
|
||||
setUnfreezeError(null);
|
||||
}}
|
||||
title={t('agentDetail.unfreezeAgent')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="font-sans text-sm text-ink-dim">
|
||||
{t('agentDetail.unfreezeDescPre')}<strong className="text-ink">{agentId}</strong>{t('agentDetail.unfreezeDescPost')}
|
||||
</p>
|
||||
{unfreezeError && (
|
||||
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{unfreezeError}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="section-label block mb-1.5">{t('agentDetail.reason')}</label>
|
||||
<textarea
|
||||
value={unfreezeReason}
|
||||
onChange={(e) => setUnfreezeReason(e.target.value)}
|
||||
placeholder={t('agentDetail.unfreezePlaceholder')}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => {
|
||||
setShowUnfreezeModal(false);
|
||||
setUnfreezeError(null);
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button className="btn-brutalist" onClick={handleUnfreeze} disabled={unfreezing}>
|
||||
{unfreezing ? t('agentDetail.unfreezing') : t('agentDetail.confirmUnfreeze')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Revoke Key Modal */}
|
||||
<Modal
|
||||
isOpen={revokeKeyId !== null}
|
||||
onClose={() => {
|
||||
setRevokeKeyId(null);
|
||||
setRevokeError(null);
|
||||
}}
|
||||
title={t('agentDetail.revokeKey')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="font-sans text-sm text-ink-dim">
|
||||
{t('agentDetail.revokeKeyDescPre')}<strong className="text-ink font-mono text-[13px]">{revokeKeyId}</strong>{t('agentDetail.revokeKeyDescPost')}
|
||||
</p>
|
||||
{revokeError && (
|
||||
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{revokeError}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="section-label block mb-1.5">{t('agentDetail.reason')}</label>
|
||||
<textarea
|
||||
value={revokeReason}
|
||||
onChange={(e) => setRevokeReason(e.target.value)}
|
||||
placeholder={t('agentDetail.revokePlaceholder')}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
className="btn-ghost"
|
||||
onClick={() => {
|
||||
setRevokeKeyId(null);
|
||||
setRevokeError(null);
|
||||
}}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button className="btn-brutalist" onClick={handleRevokeKey} disabled={revoking}>
|
||||
{revoking ? t('agentDetail.revoking') : t('agentDetail.confirmRevoke')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { AuthProvider, useAuth } from '@/lib/auth';
|
||||
import Sidebar from '@/components/ui/Sidebar';
|
||||
|
||||
function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
const closeSidebar = useCallback(() => setSidebarOpen(false), []);
|
||||
|
||||
// Close sidebar on route change (mobile)
|
||||
useEffect(() => {
|
||||
setSidebarOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
const isPublicRoute = pathname === '/login' || pathname === '/register';
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated && !isPublicRoute) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [isLoading, isAuthenticated, isPublicRoute, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="min-h-screen bg-bg" />;
|
||||
}
|
||||
|
||||
if (isPublicRoute) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <div className="min-h-screen bg-bg" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Sidebar isOpen={sidebarOpen} onClose={closeSidebar} />
|
||||
|
||||
{/* Mobile header bar */}
|
||||
<div className="fixed top-0 left-0 right-0 z-30 flex items-center gap-3 px-4 h-14 bg-ink md:hidden">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="p-2 -ml-2 text-[#EAEAE5]"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M3 5h14M3 10h14M3 15h14" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="font-sans text-sm font-semibold tracking-wide text-[#EAEAE5]">ELYDORA</span>
|
||||
</div>
|
||||
|
||||
<main className="min-h-screen md:ml-[260px]">
|
||||
<div className="max-w-[1400px] mx-auto px-4 md:px-6 lg:px-12 pt-20 md:pt-8 pb-8 overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AppShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AuthGuard>{children}</AuthGuard>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import CopyButton from '@/components/ui/CopyButton';
|
||||
import { truncateHash } from '@/lib/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ChainBlock {
|
||||
label: string;
|
||||
hash: string;
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
interface ChainVisualizationProps {
|
||||
prevHash: string;
|
||||
currentHash: string;
|
||||
operationId: string;
|
||||
}
|
||||
|
||||
function Block({ label, hash, isCurrent }: ChainBlock) {
|
||||
return (
|
||||
<div
|
||||
className={`border ${
|
||||
isCurrent ? 'border-ink bg-white' : 'border-border bg-surface'
|
||||
} p-3 sm:p-4 min-w-[140px] sm:min-w-[180px] shrink-0`}
|
||||
>
|
||||
<div className="section-label mb-2 truncate">{label}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[11px] sm:text-[12px] text-ink">
|
||||
{truncateHash(hash, 8)}
|
||||
</span>
|
||||
<CopyButton text={hash} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Arrow() {
|
||||
return (
|
||||
<div className="flex items-center px-1 sm:px-2 shrink-0">
|
||||
<svg width="24" height="12" viewBox="0 0 24 12" fill="none" className="text-ink-dim sm:w-8">
|
||||
<path d="M0 6h20M16 1l5 5-5 5" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChainVisualization({
|
||||
prevHash,
|
||||
currentHash,
|
||||
operationId,
|
||||
}: ChainVisualizationProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="border border-border bg-surface p-4 sm:p-6 overflow-hidden">
|
||||
<h3 className="section-label mb-4">{t('chain.title')}</h3>
|
||||
|
||||
<div className="flex items-center overflow-x-auto pb-2 -mx-1 px-1">
|
||||
<Block label={t('chain.previous')} hash={prevHash} />
|
||||
<Arrow />
|
||||
<Block label={t('chain.current', { id: truncateHash(operationId, 6) })} hash={currentHash} isCurrent />
|
||||
<Arrow />
|
||||
<div className="border border-border border-dashed p-3 sm:p-4 min-w-[140px] sm:min-w-[180px] bg-surface shrink-0">
|
||||
<div className="section-label mb-2">{t('chain.next')}</div>
|
||||
<span className="font-mono text-[12px] text-ink-dim italic">
|
||||
{t('chain.pending')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<div className="flex items-center gap-4 sm:gap-6 text-[11px] font-mono text-ink-dim flex-wrap">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 border border-border bg-surface inline-block" />
|
||||
{t('chain.chainBlock')}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 border border-ink bg-white inline-block" />
|
||||
{t('chain.currentOperation')}
|
||||
</span>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 border border-dashed border-border bg-surface inline-block" />
|
||||
{t('chain.pending')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
'use client';
|
||||
|
||||
import { useEpoch, formatTimestamp, truncateHash } from '@/lib/hooks';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import CopyButton from '@/components/ui/CopyButton';
|
||||
import Link from 'next/link';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function EpochDetailClient({ epochId }: { epochId: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, isLoading, error } = useEpoch(epochId);
|
||||
const epoch = data?.epoch;
|
||||
const anchor = data?.anchor;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('epochDetail.title')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('common.epochs'), href: '/epochs' },
|
||||
{ label: epochId.slice(0, 16) + '...' },
|
||||
]}
|
||||
/>
|
||||
<div className="border border-border bg-surface p-4 sm:p-6">
|
||||
<div className="space-y-3">
|
||||
<div className="skeleton h-6 w-64" />
|
||||
<div className="skeleton h-4 w-full" />
|
||||
<div className="skeleton h-4 w-3/4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !epoch) {
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('epochDetail.title')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('common.epochs'), href: '/epochs' },
|
||||
{ label: epochId.slice(0, 16) + '...' },
|
||||
]}
|
||||
/>
|
||||
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('epochDetail.failedToLoad')} {error instanceof Error ? error.message : t('epochDetail.mayNotExist')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const durationMs = epoch.end_time - epoch.start_time;
|
||||
const durationMinutes = Math.round(durationMs / 60000);
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('epochDetail.title')}
|
||||
subtitle={epoch.epoch_id}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('common.epochs'), href: '/epochs' },
|
||||
{ label: epoch.epoch_id.slice(0, 16) + '...' },
|
||||
]}
|
||||
actions={
|
||||
<Link
|
||||
href={`/audit?start_time=${new Date(epoch.start_time).toISOString().slice(0, 16)}&end_time=${new Date(epoch.end_time).toISOString().slice(0, 16)}`}
|
||||
className="btn-ghost inline-block no-underline"
|
||||
>
|
||||
{t('epochDetail.viewOperations')}
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Epoch Metadata */}
|
||||
<div className="border border-border bg-surface p-4 sm:p-6 relative">
|
||||
<span className="crosshair ch-tl" />
|
||||
<span className="crosshair ch-tr" />
|
||||
<span className="crosshair ch-bl" />
|
||||
<span className="crosshair ch-br" />
|
||||
|
||||
<h3 className="section-label mb-4">{t('epochDetail.epochMetadata')}</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('epochDetail.epochId')}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[13px] text-ink break-all">{epoch.epoch_id}</span>
|
||||
<CopyButton text={epoch.epoch_id} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('epochDetail.organization')}</div>
|
||||
<span className="font-mono text-[13px] text-ink">{epoch.org_id}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('epochDetail.startTime')}</div>
|
||||
<span className="font-mono text-[12px] text-ink">
|
||||
{formatTimestamp(epoch.start_time)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('epochDetail.endTime')}</div>
|
||||
<span className="font-mono text-[12px] text-ink">
|
||||
{formatTimestamp(epoch.end_time)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('epochDetail.duration')}</div>
|
||||
<span className="font-mono text-[13px] text-ink">
|
||||
{t('epochDetail.durationMinutes', { count: durationMinutes })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('epochDetail.createdAt')}</div>
|
||||
<span className="font-mono text-[12px] text-ink-dim">
|
||||
{formatTimestamp(epoch.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Merkle Root */}
|
||||
<div className="border border-border bg-surface p-4 sm:p-6">
|
||||
<h3 className="section-label mb-4">{t('epochDetail.merkleTree')}</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('epochDetail.rootHash')}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[13px] text-ink break-all">
|
||||
{epoch.root_hash}
|
||||
</span>
|
||||
<CopyButton text={epoch.root_hash} className="shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('epochDetail.leafCount')}</div>
|
||||
<span className="font-sans text-2xl font-semibold text-ink">
|
||||
{epoch.leaf_count}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-ink-dim ml-2">
|
||||
{t('epochDetail.operationsUnit')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TSA Anchor */}
|
||||
<div className="border border-border bg-surface p-4 sm:p-6">
|
||||
<h3 className="section-label mb-4">{t('epochDetail.tsaAnchor')}</h3>
|
||||
|
||||
{anchor?.tsa_token ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="md:col-span-2">
|
||||
<div className="section-label mb-1">{t('epochDetail.tsaToken')}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[12px] text-ink break-all">
|
||||
{truncateHash(anchor.tsa_token, 20)}
|
||||
</span>
|
||||
<CopyButton text={anchor.tsa_token} className="shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{anchor.tsa_url && (
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('epochDetail.tsaUrl')}</div>
|
||||
<span className="font-mono text-[12px] text-ink">
|
||||
{anchor.tsa_url}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{anchor.anchored_at && (
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('epochDetail.anchoredAt')}</div>
|
||||
<span className="font-mono text-[12px] text-ink">
|
||||
{formatTimestamp(anchor.anchored_at)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="font-mono text-sm text-ink-dim">
|
||||
{t('epochDetail.noTsaAnchor')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* R2 Storage */}
|
||||
<div className="border border-border bg-surface p-4 sm:p-6">
|
||||
<h3 className="section-label mb-4">{t('epochDetail.storage')}</h3>
|
||||
<div>
|
||||
<div className="section-label mb-1">{t('epochDetail.r2EpochKey')}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-mono text-[13px] text-ink break-all">
|
||||
{epoch.r2_epoch_key}
|
||||
</span>
|
||||
<CopyButton text={epoch.r2_epoch_key} className="shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import '@/i18n/config';
|
||||
|
||||
export default function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.lang = i18n.language;
|
||||
const handleChange = (lng: string) => {
|
||||
document.documentElement.lang = lng;
|
||||
};
|
||||
i18n.on('languageChanged', handleChange);
|
||||
return () => { i18n.off('languageChanged', handleChange); };
|
||||
}, [i18n]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import CopyButton from '@/components/ui/CopyButton';
|
||||
import { formatTimestamp, truncateHash } from '@/lib/hooks';
|
||||
import type { Operation, Receipt } from '@elydora/shared';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface OperationDetailCardProps {
|
||||
operation: Operation;
|
||||
receipt?: Receipt;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface FieldRowProps {
|
||||
label: string;
|
||||
value: string | number | null | undefined;
|
||||
mono?: boolean;
|
||||
copyable?: boolean;
|
||||
}
|
||||
|
||||
function FieldRow({ label, value, mono = true, copyable = false }: FieldRowProps) {
|
||||
const displayValue = value == null ? '\u2014' : String(value);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-start py-2.5 border-b border-border last:border-b-0 gap-1 sm:gap-0">
|
||||
<div className="sm:w-44 sm:shrink-0">
|
||||
<span className="section-label">{label}</span>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className={`${mono ? 'font-mono text-[13px]' : 'font-sans text-sm'} text-ink break-all`}
|
||||
>
|
||||
{displayValue}
|
||||
</span>
|
||||
{copyable && value != null && (
|
||||
<CopyButton text={String(value)} className="shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OperationDetailCard({ operation, receipt, payload }: OperationDetailCardProps) {
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Operation record */}
|
||||
<div className="border border-border bg-surface p-4 sm:p-6 relative">
|
||||
<span className="crosshair ch-tl" />
|
||||
<span className="crosshair ch-tr" />
|
||||
<span className="crosshair ch-bl" />
|
||||
<span className="crosshair ch-br" />
|
||||
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="section-label">{t('operationCard.operationRecord')}</h3>
|
||||
<button
|
||||
onClick={() => setShowRaw(!showRaw)}
|
||||
className="btn-ghost text-[10px] py-1 px-3"
|
||||
>
|
||||
{showRaw ? t('operationCard.formatted') : t('operationCard.rawJson')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showRaw ? (
|
||||
<div className="relative">
|
||||
<pre className="font-mono text-[12px] text-ink bg-bg p-4 border border-border overflow-x-auto">
|
||||
{JSON.stringify(operation, null, 2)}
|
||||
</pre>
|
||||
<div className="absolute top-2 right-2">
|
||||
<CopyButton text={JSON.stringify(operation, null, 2)} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<FieldRow label={t('operationCard.operationId')} value={operation.operation_id} copyable />
|
||||
<FieldRow label={t('operationCard.agentId')} value={operation.agent_id} copyable />
|
||||
<FieldRow label={t('operationCard.orgId')} value={operation.org_id} copyable />
|
||||
<FieldRow label={t('operationCard.type')} value={operation.operation_type} />
|
||||
<FieldRow label={t('operationCard.issuedAt')} value={formatTimestamp(operation.issued_at)} mono={false} />
|
||||
<FieldRow label={t('operationCard.createdAt')} value={formatTimestamp(operation.created_at)} mono={false} />
|
||||
<FieldRow label={t('operationCard.ttlMs')} value={operation.ttl_ms} />
|
||||
<FieldRow label={t('operationCard.nonce')} value={operation.nonce} copyable />
|
||||
<FieldRow label={t('operationCard.seqNo')} value={operation.seq_no} />
|
||||
<FieldRow label={t('operationCard.signingKey')} value={operation.agent_pubkey_kid} copyable />
|
||||
<FieldRow label={t('operationCard.payloadHash')} value={truncateHash(operation.payload_hash, 12)} copyable />
|
||||
<FieldRow label={t('operationCard.prevChainHash')} value={truncateHash(operation.prev_chain_hash, 12)} copyable />
|
||||
<FieldRow label={t('operationCard.chainHash')} value={truncateHash(operation.chain_hash, 12)} copyable />
|
||||
<FieldRow label={t('operationCard.r2PayloadKey')} value={operation.r2_payload_key ?? '—'} copyable />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payload — what the operation actually did */}
|
||||
{payload && (
|
||||
<div className="border border-border bg-surface p-4 sm:p-6 relative">
|
||||
<span className="crosshair ch-tl" />
|
||||
<span className="crosshair ch-tr" />
|
||||
<span className="crosshair ch-bl" />
|
||||
<span className="crosshair ch-br" />
|
||||
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="section-label">{t('operationCard.payload')}</h3>
|
||||
<div className="flex-1" />
|
||||
<CopyButton text={JSON.stringify(payload, null, 2)} />
|
||||
</div>
|
||||
{Object.entries(payload).map(([key, value]) => {
|
||||
if (value == null) return null;
|
||||
const isComplex = typeof value === 'object';
|
||||
if (isComplex) {
|
||||
return (
|
||||
<div key={key} className="py-2.5 border-b border-border last:border-b-0">
|
||||
<div className="mb-1">
|
||||
<span className="section-label">{key.replace(/_/g, ' ')}</span>
|
||||
</div>
|
||||
<pre className="font-mono text-[12px] text-ink bg-bg p-3 border border-border max-h-64 overflow-y-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FieldRow key={key} label={key.replace(/_/g, ' ')} value={String(value)} copyable />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Receipt */}
|
||||
{receipt && (
|
||||
<div className="border border-border bg-surface p-4 sm:p-6">
|
||||
<h3 className="section-label mb-4">{t('operationCard.receipt')}</h3>
|
||||
<FieldRow label={t('operationCard.receiptId')} value={receipt.receipt_id} copyable />
|
||||
<FieldRow label={t('operationCard.operationId')} value={receipt.operation_id} copyable />
|
||||
<FieldRow label={t('operationCard.r2ReceiptKey')} value={receipt.r2_receipt_key} copyable />
|
||||
<FieldRow label={t('operationCard.createdAt')} value={formatTimestamp(receipt.created_at)} mono={false} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useOperation } from '@/lib/hooks';
|
||||
import { api } from '@/lib/api';
|
||||
import PageHeader from '@/components/ui/PageHeader';
|
||||
import OperationDetailCard from '@/components/OperationDetailCard';
|
||||
import ChainVisualization from '@/components/ChainVisualization';
|
||||
import VerificationChecklist from '@/components/VerificationChecklist';
|
||||
import type { VerifyOperationResponse } from '@elydora/shared';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function OperationDetailClient({ operationId }: { operationId: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, isLoading, error } = useOperation(operationId);
|
||||
const [verifyResult, setVerifyResult] = useState<VerifyOperationResponse | null>(null);
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
|
||||
const operation = data?.operation;
|
||||
const receipt = data?.receipt;
|
||||
const payload = data?.payload;
|
||||
|
||||
const handleVerify = useCallback(async () => {
|
||||
setVerifying(true);
|
||||
try {
|
||||
const result = await api.operations.verify(operationId);
|
||||
setVerifyResult(result);
|
||||
} catch (err) {
|
||||
setVerifyResult({
|
||||
valid: false,
|
||||
checks: {
|
||||
signature: false,
|
||||
chain: false,
|
||||
receipt: false,
|
||||
},
|
||||
errors: [err instanceof Error ? err.message : 'Verification request failed.'],
|
||||
});
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
}, [operationId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('operationDetail.title')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('common.operations'), href: '/operations' },
|
||||
{ label: operationId.slice(0, 16) + '...' },
|
||||
]}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div className="border border-border bg-surface p-6">
|
||||
<div className="space-y-3">
|
||||
<div className="skeleton h-6 w-64" />
|
||||
<div className="skeleton h-4 w-full" />
|
||||
<div className="skeleton h-4 w-3/4" />
|
||||
<div className="skeleton h-4 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !operation) {
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('operationDetail.title')}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('common.operations'), href: '/operations' },
|
||||
{ label: operationId.slice(0, 16) + '...' },
|
||||
]}
|
||||
/>
|
||||
<div className="px-4 py-3 border border-red-300 bg-red-50 font-mono text-[12px] text-red-700">
|
||||
{t('operationDetail.failedToLoad')} {error instanceof Error ? error.message : t('operationDetail.mayNotExist')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<PageHeader
|
||||
title={t('operationDetail.title')}
|
||||
subtitle={operation.operation_id}
|
||||
breadcrumbs={[
|
||||
{ label: t('common.dashboard'), href: '/' },
|
||||
{ label: t('common.operations'), href: '/operations' },
|
||||
{ label: operation.operation_id.slice(0, 16) + '...' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
<OperationDetailCard operation={operation} receipt={receipt} payload={payload} />
|
||||
<ChainVisualization
|
||||
prevHash={operation.prev_chain_hash}
|
||||
currentHash={operation.chain_hash}
|
||||
operationId={operation.operation_id}
|
||||
/>
|
||||
<VerificationChecklist
|
||||
result={verifyResult}
|
||||
isLoading={verifying}
|
||||
onVerify={handleVerify}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import type { VerifyOperationResponse } from '@elydora/shared';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface VerificationChecklistProps {
|
||||
result: VerifyOperationResponse | null;
|
||||
isLoading: boolean;
|
||||
onVerify: () => void;
|
||||
}
|
||||
|
||||
interface CheckItemProps {
|
||||
label: string;
|
||||
description: string;
|
||||
passed: boolean | undefined;
|
||||
pendingText: string;
|
||||
passedText: string;
|
||||
failedText: string;
|
||||
}
|
||||
|
||||
function CheckItem({ label, description, passed, pendingText, passedText, failedText }: CheckItemProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-3 border-b border-border last:border-b-0">
|
||||
<div className="mt-0.5 shrink-0">
|
||||
{passed === undefined ? (
|
||||
<div className="w-5 h-5 border border-border rounded-full" />
|
||||
) : passed ? (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="check-pass">
|
||||
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M6 10l3 3 5-6" stroke="currentColor" strokeWidth="1.5" fill="none" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" className="check-fail">
|
||||
<circle cx="10" cy="10" r="9" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M7 7l6 6M13 7l-6 6" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-mono text-[12px] font-medium uppercase tracking-wider text-ink">
|
||||
{label}
|
||||
</div>
|
||||
<div className="font-sans text-[12px] text-ink-dim mt-0.5">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
{passed === undefined ? (
|
||||
<span className="font-mono text-[11px] text-ink-dim uppercase">{pendingText}</span>
|
||||
) : passed ? (
|
||||
<span className="font-mono text-[11px] text-green-600 uppercase">{passedText}</span>
|
||||
) : (
|
||||
<span className="font-mono text-[11px] text-red-600 uppercase">{failedText}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VerificationChecklist({
|
||||
result,
|
||||
isLoading,
|
||||
onVerify,
|
||||
}: VerificationChecklistProps) {
|
||||
const { t } = useTranslation();
|
||||
const checks = result?.checks;
|
||||
|
||||
return (
|
||||
<div className="border border-border bg-surface p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="section-label">{t('verification.title')}</h3>
|
||||
<button
|
||||
onClick={onVerify}
|
||||
className="btn-brutalist text-[10px] py-1.5 px-4"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? t('verification.verifying') : result ? t('verification.reVerify') : t('verification.verify')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="mb-4 px-4 py-3 border font-mono text-[12px] flex items-center gap-2"
|
||||
style={{
|
||||
borderColor: result.valid ? '#16a34a' : '#dc2626',
|
||||
backgroundColor: result.valid ? 'rgba(22, 163, 74, 0.05)' : 'rgba(220, 38, 38, 0.05)',
|
||||
color: result.valid ? '#16a34a' : '#dc2626',
|
||||
}}
|
||||
>
|
||||
{result.valid ? (
|
||||
<>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M2 7.5l3 3 7-7" />
|
||||
</svg>
|
||||
{t('verification.verifiedSuccess')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M1 1l12 12M13 1L1 13" />
|
||||
</svg>
|
||||
{t('verification.verificationFailed')}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<CheckItem
|
||||
label={t('verification.signature')}
|
||||
description={t('verification.signatureDesc')}
|
||||
passed={checks?.signature}
|
||||
pendingText={t('verification.pending')}
|
||||
passedText={t('verification.passed')}
|
||||
failedText={t('verification.failed')}
|
||||
/>
|
||||
<CheckItem
|
||||
label={t('verification.chainCheck')}
|
||||
description={t('verification.chainCheckDesc')}
|
||||
passed={checks?.chain}
|
||||
pendingText={t('verification.pending')}
|
||||
passedText={t('verification.passed')}
|
||||
failedText={t('verification.failed')}
|
||||
/>
|
||||
<CheckItem
|
||||
label={t('verification.receiptCheck')}
|
||||
description={t('verification.receiptCheckDesc')}
|
||||
passed={checks?.receipt}
|
||||
pendingText={t('verification.pending')}
|
||||
passedText={t('verification.passed')}
|
||||
failedText={t('verification.failed')}
|
||||
/>
|
||||
<CheckItem
|
||||
label={t('verification.merkle')}
|
||||
description={t('verification.merkleDesc')}
|
||||
passed={checks?.merkle}
|
||||
pendingText={t('verification.pending')}
|
||||
passedText={t('verification.passed')}
|
||||
failedText={t('verification.failed')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{result?.errors && result.errors.length > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<div className="section-label mb-2">{t('verification.errors')}</div>
|
||||
<ul className="space-y-1">
|
||||
{result.errors.map((err, i) => (
|
||||
<li key={i} className="font-mono text-[12px] text-red-600">
|
||||
{err}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { useClipboard } from '@/lib/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface CopyButtonProps {
|
||||
text: string;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function CopyButton({ text, label, className = '' }: CopyButtonProps) {
|
||||
const { copied, copy } = useClipboard();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => copy(text)}
|
||||
className={`inline-flex items-center gap-1.5 font-mono text-[12px] text-ink-dim hover:text-ink transition-colors ${className}`}
|
||||
title={t('common.copyToClipboard')}
|
||||
>
|
||||
{label && <span>{label}</span>}
|
||||
{copied ? (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-green-600">
|
||||
<path d="M2 7.5l3 3 7-7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="4" y="4" width="9" height="9" rx="1" />
|
||||
<path d="M10 4V2a1 1 0 00-1-1H2a1 1 0 00-1 1v7a1 1 0 001 1h2" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
render?: (row: T) => React.ReactNode;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
keyExtractor: (row: T) => string;
|
||||
onRowClick?: (row: T) => void;
|
||||
isLoading?: boolean;
|
||||
emptyMessage?: string;
|
||||
pagination?: {
|
||||
cursor?: string;
|
||||
hasMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
loadingMore?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export default function DataTable<T extends Record<string, unknown>>({
|
||||
columns,
|
||||
data,
|
||||
keyExtractor,
|
||||
onRowClick,
|
||||
isLoading,
|
||||
emptyMessage = 'No data available.',
|
||||
pagination,
|
||||
}: DataTableProps<T>) {
|
||||
const [sortKey, setSortKey] = useState<string | null>(null);
|
||||
const [sortDir, setSortDir] = useState<SortDirection>('asc');
|
||||
|
||||
const handleSort = useCallback((key: string) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir('asc');
|
||||
}
|
||||
}, [sortKey]);
|
||||
|
||||
const sortedData = sortKey
|
||||
? [...data].sort((a, b) => {
|
||||
const aVal = a[sortKey];
|
||||
const bVal = b[sortKey];
|
||||
if (aVal == null && bVal == null) return 0;
|
||||
if (aVal == null) return sortDir === 'asc' ? -1 : 1;
|
||||
if (bVal == null) return sortDir === 'asc' ? 1 : -1;
|
||||
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
||||
return sortDir === 'asc'
|
||||
? aVal.localeCompare(bVal)
|
||||
: bVal.localeCompare(aVal);
|
||||
}
|
||||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
return sortDir === 'asc' ? aVal - bVal : bVal - aVal;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
: data;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="border border-border">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="table-header text-left px-4 py-3"
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={i} className="border-b border-border last:border-b-0">
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
<div className="skeleton h-4 w-3/4" />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="border border-border bg-surface">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="table-header text-left px-4 py-3"
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
</table>
|
||||
<div className="py-16 text-center">
|
||||
<p className="font-mono text-sm text-ink-dim">{emptyMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-border overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`table-header text-left px-4 py-3 ${
|
||||
col.sortable ? 'cursor-pointer select-none hover:text-ink' : ''
|
||||
}`}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
onClick={col.sortable ? () => handleSort(col.key) : undefined}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{col.label}
|
||||
{col.sortable && sortKey === col.key && (
|
||||
<span className="text-ink">
|
||||
{sortDir === 'asc' ? '\u2191' : '\u2193'}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedData.map((row) => (
|
||||
<tr
|
||||
key={keyExtractor(row)}
|
||||
className={`data-row border-b border-border last:border-b-0 ${
|
||||
onRowClick ? 'cursor-pointer' : ''
|
||||
}`}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="px-4 py-3 font-mono text-[13px]">
|
||||
{col.render
|
||||
? col.render(row)
|
||||
: String(row[col.key] ?? '')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{pagination && pagination.hasMore && (
|
||||
<div className="border-t border-border px-4 py-3 flex justify-center">
|
||||
<button
|
||||
className="btn-ghost text-[11px]"
|
||||
onClick={pagination.onLoadMore}
|
||||
disabled={pagination.loadingMore}
|
||||
>
|
||||
{pagination.loadingMore ? 'Loading...' : 'Load More'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function USFlag() {
|
||||
return (
|
||||
<svg width="20" height="14" viewBox="0 0 20 14" style={{ borderRadius: 2, flexShrink: 0 }}>
|
||||
<rect width="20" height="14" fill="#B22234" />
|
||||
<rect y="2" width="20" height="2" fill="#fff" />
|
||||
<rect y="6" width="20" height="2" fill="#fff" />
|
||||
<rect y="10" width="20" height="2" fill="#fff" />
|
||||
<rect width="8" height="8" fill="#3C3B6E" />
|
||||
<g fill="#fff">
|
||||
<circle cx="2" cy="1.5" r="0.5" />
|
||||
<circle cx="4" cy="1.5" r="0.5" />
|
||||
<circle cx="6" cy="1.5" r="0.5" />
|
||||
<circle cx="3" cy="3" r="0.5" />
|
||||
<circle cx="5" cy="3" r="0.5" />
|
||||
<circle cx="2" cy="4.5" r="0.5" />
|
||||
<circle cx="4" cy="4.5" r="0.5" />
|
||||
<circle cx="6" cy="4.5" r="0.5" />
|
||||
<circle cx="3" cy="6" r="0.5" />
|
||||
<circle cx="5" cy="6" r="0.5" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CNFlag() {
|
||||
return (
|
||||
<svg width="20" height="14" viewBox="0 0 20 14" style={{ borderRadius: 2, flexShrink: 0 }}>
|
||||
<rect width="20" height="14" fill="#DE2910" />
|
||||
<g fill="#FFDE00">
|
||||
<polygon points="4,1.5 4.7,3.6 6.9,3.6 5.1,5 5.8,7 4,5.7 2.2,7 2.9,5 1.1,3.6 3.3,3.6" />
|
||||
<polygon points="8.5,1 9,1.8 8.5,1.6 8,1.8" />
|
||||
<polygon points="10,2.5 10.3,3.3 9.7,2.9 9.3,3.3 9.5,2.5" />
|
||||
<polygon points="10,4.5 10.3,5.3 9.7,4.9 9.3,5.3 9.5,4.5" />
|
||||
<polygon points="8.5,6 9,6.8 8.5,6.6 8,6.8" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const languages = [
|
||||
{ code: 'en', label: 'EN', flag: <USFlag /> },
|
||||
{ code: 'zh', label: '中文', flag: <CNFlag /> },
|
||||
];
|
||||
|
||||
export function LanguageDropdown() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const current = i18n.language.startsWith('zh') ? languages[1]! : languages[0]!;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 px-2 py-1.5 font-mono text-[11px] rounded transition-colors w-full"
|
||||
style={{
|
||||
color: '#EAEAE5',
|
||||
background: 'rgba(234,234,229,0.08)',
|
||||
border: '1px solid rgba(234,234,229,0.15)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{current.flag}
|
||||
<span>{current.label}</span>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ marginLeft: 'auto', transform: open ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }}>
|
||||
<path d="M2 4l3 3 3-3" />
|
||||
</svg>
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className="absolute left-0 right-0 z-50 rounded overflow-hidden"
|
||||
style={{
|
||||
bottom: '100%',
|
||||
marginBottom: 4,
|
||||
backgroundColor: '#1a1a18',
|
||||
border: '1px solid rgba(234,234,229,0.15)',
|
||||
}}
|
||||
>
|
||||
{languages.map((lang) => {
|
||||
const isActive = lang.code === 'en' ? i18n.language === 'en' : i18n.language.startsWith('zh');
|
||||
return (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => { i18n.changeLanguage(lang.code); setOpen(false); }}
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 font-mono text-[11px] transition-colors"
|
||||
style={{
|
||||
color: isActive ? '#EAEAE5' : 'rgba(234,234,229,0.5)',
|
||||
background: isActive ? 'rgba(234,234,229,0.1)' : 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = 'rgba(234,234,229,0.06)'; }}
|
||||
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||
>
|
||||
{lang.flag}
|
||||
{lang.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useCallback, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export default function Modal({ isOpen, onClose, title, children, width = 'max-w-lg' }: ModalProps) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen, handleKeyDown]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="fixed inset-0 md:left-[260px] z-[100] flex items-center justify-center modal-overlay"
|
||||
onClick={(e) => {
|
||||
if (e.target === overlayRef.current) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`${width} w-full mx-4 bg-bg border border-ink relative fade-in`}
|
||||
>
|
||||
{/* Crosshair corners */}
|
||||
<span className="crosshair ch-tl" />
|
||||
<span className="crosshair ch-tr" />
|
||||
<span className="crosshair ch-bl" />
|
||||
<span className="crosshair ch-br" />
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 sm:px-6 py-4 border-b border-border">
|
||||
<h2 className="font-sans text-lg font-semibold tracking-tight text-ink min-w-0 truncate">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 flex items-center justify-center text-ink-dim hover:text-ink transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M1 1l12 12M13 1L1 13" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-4 sm:px-6 py-5 max-h-[calc(100vh-8rem)] overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Breadcrumb {
|
||||
label: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
breadcrumbs?: Breadcrumb[];
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function PageHeader({ title, subtitle, breadcrumbs, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
{breadcrumbs && breadcrumbs.length > 0 && (
|
||||
<nav className="mb-3 flex items-center gap-2 flex-wrap overflow-hidden">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<span key={index} className="flex items-center gap-2 min-w-0">
|
||||
{index > 0 && (
|
||||
<span className="font-mono text-[11px] text-ink-dim shrink-0">/</span>
|
||||
)}
|
||||
{crumb.href ? (
|
||||
<Link
|
||||
href={crumb.href}
|
||||
className="font-mono text-[11px] uppercase tracking-wider text-ink-dim hover:text-ink transition-colors no-underline whitespace-nowrap"
|
||||
>
|
||||
{crumb.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-mono text-[11px] uppercase tracking-wider text-ink truncate">
|
||||
{crumb.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-6">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-sans text-2xl font-semibold tracking-tight text-ink leading-tight">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle && (
|
||||
<p className="mt-1 font-mono text-[13px] text-ink-dim break-all">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{actions && (
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
interface SearchInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SearchInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search...',
|
||||
className = '',
|
||||
}: SearchInputProps) {
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-ink-dim"
|
||||
>
|
||||
<circle cx="7" cy="7" r="5" />
|
||||
<path d="M11 11l4 4" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-transparent border border-border font-mono text-[13px] text-ink placeholder:text-ink-dim focus:outline-none focus:border-ink transition-colors"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LanguageDropdown } from './LanguageDropdown';
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
title: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const { user, logout } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const sections: NavSection[] = [
|
||||
{
|
||||
title: t('sidebar.overview'),
|
||||
items: [
|
||||
{
|
||||
label: t('common.dashboard'),
|
||||
href: '/',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<rect x="1" y="1" width="6" height="6" rx="1" />
|
||||
<rect x="9" y="1" width="6" height="6" rx="1" />
|
||||
<rect x="1" y="9" width="6" height="6" rx="1" />
|
||||
<rect x="9" y="9" width="6" height="6" rx="1" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('sidebar.management'),
|
||||
items: [
|
||||
{
|
||||
label: t('common.agents'),
|
||||
href: '/agents',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="8" cy="5" r="3" />
|
||||
<path d="M2 14c0-3.3 2.7-6 6-6s6 2.7 6 6" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t('common.operations'),
|
||||
href: '/operations',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M2 4h12M2 8h12M2 12h12" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('sidebar.compliance'),
|
||||
items: [
|
||||
{
|
||||
label: t('common.auditTrail'),
|
||||
href: '/audit',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M4 1v14M12 1v14M1 4h14M1 12h14" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t('common.epochs'),
|
||||
href: '/epochs',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="8" cy="8" r="6" />
|
||||
<path d="M8 4v4l3 3" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t('common.exports'),
|
||||
href: '/exports',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M8 2v8M5 7l3 3 3-3M3 12v2h10v-2" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('sidebar.system'),
|
||||
items: [
|
||||
{
|
||||
label: t('common.jwks'),
|
||||
href: '/jwks',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M8 1l2 3h3l-2.5 3L12 11l-4-2-4 2 1.5-4L3 4h3z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t('common.settings'),
|
||||
href: '/settings',
|
||||
icon: (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<circle cx="8" cy="8" r="2" />
|
||||
<path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.05 3.05l1.41 1.41M11.54 11.54l1.41 1.41M3.05 12.95l1.41-1.41M11.54 4.46l1.41-1.41" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
if (href === '/') return pathname === '/';
|
||||
return pathname.startsWith(href);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop overlay — mobile only */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 md:hidden"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={`fixed left-0 top-0 bottom-0 bg-ink text-[#EAEAE5] flex flex-col z-50 transition-transform duration-300 ease-in-out md:translate-x-0 ${isOpen ? 'translate-x-0' : '-translate-x-full'}`}
|
||||
style={{ width: 'var(--sidebar-width)' }}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="px-5 py-6 border-b border-[rgba(234,234,229,0.1)]">
|
||||
<Link href="/" className="flex items-center gap-3 no-underline" onClick={onClose}>
|
||||
<div className="w-8 h-8 border border-[#EAEAE5] flex items-center justify-center">
|
||||
<span className="font-mono text-sm font-bold text-[#EAEAE5]">E</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-sans text-sm font-semibold tracking-wide text-[#EAEAE5]">
|
||||
ELYDORA
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-[rgba(234,234,229,0.4)] tracking-widest uppercase">
|
||||
{t('common.console')}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto py-4">
|
||||
{sections.map((section) => (
|
||||
<div key={section.title} className="mb-4">
|
||||
<div className="px-5 py-2 font-mono text-[10px] font-medium tracking-[0.15em] uppercase text-[rgba(234,234,229,0.3)]">
|
||||
{section.title}
|
||||
</div>
|
||||
{section.items.map((item) => (
|
||||
<Link
|
||||
key={item.href + item.label}
|
||||
href={item.href}
|
||||
onClick={onClose}
|
||||
className={`sidebar-nav-item mx-2 rounded-sm ${isActive(item.href) ? 'active' : ''}`}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* External links */}
|
||||
<div className="px-5 py-3 border-t border-[rgba(234,234,229,0.1)]">
|
||||
<div className="font-mono text-[10px] font-medium tracking-[0.15em] uppercase text-[rgba(234,234,229,0.3)] mb-2">
|
||||
{t('common.links')}
|
||||
</div>
|
||||
<a
|
||||
href="https://elydora.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-[rgba(234,234,229,0.6)] hover:text-[#EAEAE5] transition-colors no-underline font-mono text-[12px]"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M6 3H3v10h10v-3M9 2h5v5M14 2L7 9" />
|
||||
</svg>
|
||||
{t('common.home')}
|
||||
</a>
|
||||
<a
|
||||
href="https://docs.elydora.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-[rgba(234,234,229,0.6)] hover:text-[#EAEAE5] transition-colors no-underline font-mono text-[12px]"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M6 3H3v10h10v-3M9 2h5v5M14 2L7 9" />
|
||||
</svg>
|
||||
{t('common.docs')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Language dropdown */}
|
||||
<div className="px-5 py-3 border-t border-[rgba(234,234,229,0.1)]">
|
||||
<LanguageDropdown />
|
||||
</div>
|
||||
|
||||
{/* Bottom user info */}
|
||||
<div className="px-5 py-4 border-t border-[rgba(234,234,229,0.1)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-[rgba(234,234,229,0.1)] flex items-center justify-center">
|
||||
<span className="font-mono text-xs text-[rgba(234,234,229,0.6)]">
|
||||
{user?.display_name?.[0]?.toUpperCase() ?? user?.email?.[0]?.toUpperCase() ?? 'U'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-mono text-xs text-[#EAEAE5] truncate">
|
||||
{user?.display_name ?? user?.email ?? 'User'}
|
||||
</div>
|
||||
<div className="font-mono text-[10px] text-[rgba(234,234,229,0.4)] uppercase tracking-wider">
|
||||
{user?.role ?? 'unknown'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="p-1.5 rounded transition-colors hover:bg-[rgba(234,234,229,0.1)]"
|
||||
title={t('common.signOut')}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="rgba(234,234,229,0.4)" strokeWidth="1.5">
|
||||
<path d="M6 2H3v12h3M11 4l4 4-4 4M7 8h8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { AgentStatus, KeyStatus, ExportStatus } from '@elydora/shared';
|
||||
|
||||
type Status = AgentStatus | KeyStatus | ExportStatus | 'running' | 'queued' | 'done' | 'failed' | 'verified' | 'invalid';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: Status;
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
const statusConfig: Record<Status, { color: string; dotColor: string; label: string }> = {
|
||||
active: {
|
||||
color: 'text-ink',
|
||||
dotColor: 'bg-ink',
|
||||
label: 'Active',
|
||||
},
|
||||
frozen: {
|
||||
color: 'text-blue-600',
|
||||
dotColor: 'bg-blue-500',
|
||||
label: 'Frozen',
|
||||
},
|
||||
revoked: {
|
||||
color: 'text-red-600',
|
||||
dotColor: 'bg-red-500',
|
||||
label: 'Revoked',
|
||||
},
|
||||
retired: {
|
||||
color: 'text-ink-dim',
|
||||
dotColor: 'bg-ink-dim',
|
||||
label: 'Retired',
|
||||
},
|
||||
queued: {
|
||||
color: 'text-ink-dim',
|
||||
dotColor: 'bg-ink-dim',
|
||||
label: 'Queued',
|
||||
},
|
||||
running: {
|
||||
color: 'text-amber-600',
|
||||
dotColor: 'bg-amber-500',
|
||||
label: 'Running',
|
||||
},
|
||||
done: {
|
||||
color: 'text-green-600',
|
||||
dotColor: 'bg-green-500',
|
||||
label: 'Done',
|
||||
},
|
||||
failed: {
|
||||
color: 'text-red-600',
|
||||
dotColor: 'bg-red-500',
|
||||
label: 'Failed',
|
||||
},
|
||||
verified: {
|
||||
color: 'text-green-600',
|
||||
dotColor: 'bg-green-500',
|
||||
label: 'Verified',
|
||||
},
|
||||
invalid: {
|
||||
color: 'text-red-600',
|
||||
dotColor: 'bg-red-500',
|
||||
label: 'Invalid',
|
||||
},
|
||||
};
|
||||
|
||||
export default function StatusBadge({ status, size = 'md' }: StatusBadgeProps) {
|
||||
const config = statusConfig[status] ?? {
|
||||
color: 'text-ink-dim',
|
||||
dotColor: 'bg-ink-dim',
|
||||
label: status,
|
||||
};
|
||||
|
||||
const dotSize = size === 'sm' ? 'w-1.5 h-1.5' : 'w-2 h-2';
|
||||
const textSize = size === 'sm' ? 'text-[10px]' : 'text-[11px]';
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 ${config.color}`}>
|
||||
<span
|
||||
className={`${dotSize} rounded-full ${config.dotColor} ${
|
||||
status === 'running' ? 'pulse-dot' : ''
|
||||
}`}
|
||||
/>
|
||||
<span className={`font-mono ${textSize} font-medium uppercase tracking-wider`}>
|
||||
{config.label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
import en from './en.json';
|
||||
import zh from './zh.json';
|
||||
|
||||
const resources = {
|
||||
en: { translation: en },
|
||||
zh: { translation: zh },
|
||||
};
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: 'en',
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
detection: {
|
||||
order: ['localStorage', 'navigator'],
|
||||
caches: ['localStorage'],
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
@@ -0,0 +1,390 @@
|
||||
{
|
||||
"common": {
|
||||
"dashboard": "Dashboard",
|
||||
"agents": "Agents",
|
||||
"operations": "Operations",
|
||||
"epochs": "Epochs",
|
||||
"auditTrail": "Audit Trail",
|
||||
"exports": "Exports",
|
||||
"jwks": "JWKS",
|
||||
"settings": "Settings",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"download": "Download",
|
||||
"continue": "Continue",
|
||||
"signOut": "Sign Out",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"clickToCopy": "Click to copy",
|
||||
"viewAll": "View All",
|
||||
"home": "Home",
|
||||
"docs": "Docs",
|
||||
"console": "Console",
|
||||
"loading": "Loading...",
|
||||
"unknownError": "Unknown error.",
|
||||
"allData": "All data",
|
||||
"copyToClipboard": "Copy to clipboard",
|
||||
"copyJwks": "Copy JWKS",
|
||||
"links": "LINKS"
|
||||
},
|
||||
"sidebar": {
|
||||
"overview": "OVERVIEW",
|
||||
"management": "MANAGEMENT",
|
||||
"compliance": "COMPLIANCE",
|
||||
"system": "SYSTEM"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"subtitle": "Elydora accountability console overview",
|
||||
"totalAgents": "Total Agents",
|
||||
"registeredAgents": "Registered agents",
|
||||
"totalOperations": "Total Operations",
|
||||
"allRecordedOps": "All recorded operations",
|
||||
"activeEpochs": "Active Epochs",
|
||||
"merkleRollups": "Merkle rollup epochs",
|
||||
"pendingExports": "Pending Exports",
|
||||
"queuedOrRunning": "Queued or running",
|
||||
"quickActions": "Quick Actions",
|
||||
"registerAgent": "Register Agent",
|
||||
"viewOperations": "View Operations",
|
||||
"createExport": "Create Export",
|
||||
"recentOperations": "Recent Operations",
|
||||
"noOperations": "No operations recorded yet.",
|
||||
"agentSummary": "Agent Summary",
|
||||
"noAgentsRegistered": "No agents registered yet. Register an agent to start recording operations.",
|
||||
"latestActivity": "Latest Activity",
|
||||
"failedToLoad": "Failed to load dashboard data. Ensure the API is reachable.",
|
||||
"colOperationId": "Operation ID",
|
||||
"colAgent": "Agent",
|
||||
"colType": "Type",
|
||||
"colIssued": "Issued",
|
||||
"colSeq": "Seq"
|
||||
},
|
||||
"agents": {
|
||||
"title": "Agents",
|
||||
"subtitle": "Manage registered AI agents",
|
||||
"registerAgent": "Register Agent",
|
||||
"searchPlaceholder": "Search agents by ID...",
|
||||
"colAgent": "Agent",
|
||||
"colStatus": "Status",
|
||||
"colOperations": "Operations",
|
||||
"colLatestActivity": "Latest Activity",
|
||||
"emptyMessage": "No agents found. Register an agent to get started.",
|
||||
"failedToLoad": "Failed to load agents.",
|
||||
"deleteAgent": "Delete Agent",
|
||||
"deleteConfirmPre": "Are you sure you want to delete agent ",
|
||||
"deleteConfirmPost": "? This will permanently remove the agent and all its keys. This action cannot be undone.",
|
||||
"deleting": "Deleting...",
|
||||
"failedToDelete": "Failed to delete agent."
|
||||
},
|
||||
"agentDetail": {
|
||||
"title": "Agent Details",
|
||||
"agentId": "Agent ID",
|
||||
"status": "Status",
|
||||
"organization": "Organization",
|
||||
"displayName": "Display Name",
|
||||
"responsibleEntity": "Responsible Entity",
|
||||
"created": "Created",
|
||||
"keys": "Keys",
|
||||
"noKeys": "No keys registered for this agent.",
|
||||
"colKeyId": "Key ID",
|
||||
"colAlgorithm": "Algorithm",
|
||||
"colStatus": "Status",
|
||||
"colCreated": "Created",
|
||||
"revoke": "Revoke",
|
||||
"operations": "Operations",
|
||||
"freezeAgent": "Freeze Agent",
|
||||
"unfreezeAgent": "Unfreeze Agent",
|
||||
"freezeDescPre": "Freezing agent ",
|
||||
"freezeDescPost": " will prevent it from submitting new operations. This action is recorded in the audit trail.",
|
||||
"unfreezeDescPre": "Unfreezing agent ",
|
||||
"unfreezeDescPost": " will allow it to resume submitting operations. This action is recorded in the audit trail.",
|
||||
"reason": "Reason",
|
||||
"freezePlaceholder": "Provide a reason for freezing this agent...",
|
||||
"unfreezePlaceholder": "Provide a reason for unfreezing this agent...",
|
||||
"freezing": "Freezing...",
|
||||
"confirmFreeze": "Confirm Freeze",
|
||||
"unfreezing": "Unfreezing...",
|
||||
"confirmUnfreeze": "Confirm Unfreeze",
|
||||
"failedToFreeze": "Failed to freeze agent.",
|
||||
"failedToUnfreeze": "Failed to unfreeze agent.",
|
||||
"reasonRequired": "A reason is required.",
|
||||
"revokeKey": "Revoke Key",
|
||||
"revokeKeyDescPre": "Revoking key ",
|
||||
"revokeKeyDescPost": " will prevent it from being used to sign operations. This action cannot be undone.",
|
||||
"revokePlaceholder": "Provide a reason for revoking this key...",
|
||||
"revoking": "Revoking...",
|
||||
"confirmRevoke": "Confirm Revoke",
|
||||
"failedToRevoke": "Failed to revoke key.",
|
||||
"failedToLoad": "Failed to load agent.",
|
||||
"mayNotExist": "The agent may not exist.",
|
||||
"noOperations": "No operations recorded for this agent."
|
||||
},
|
||||
"operationDetail": {
|
||||
"title": "Operation Details",
|
||||
"failedToLoad": "Failed to load operation.",
|
||||
"mayNotExist": "The operation may not exist."
|
||||
},
|
||||
"operationCard": {
|
||||
"operationRecord": "Operation Record",
|
||||
"formatted": "Formatted",
|
||||
"rawJson": "Raw JSON",
|
||||
"operationId": "Operation ID",
|
||||
"agentId": "Agent ID",
|
||||
"orgId": "Org ID",
|
||||
"type": "Type",
|
||||
"issuedAt": "Issued At",
|
||||
"createdAt": "Created At",
|
||||
"ttlMs": "TTL (ms)",
|
||||
"nonce": "Nonce",
|
||||
"seqNo": "Seq No",
|
||||
"signingKey": "Signing Key",
|
||||
"payloadHash": "Payload Hash",
|
||||
"prevChainHash": "Prev Chain Hash",
|
||||
"chainHash": "Chain Hash",
|
||||
"r2PayloadKey": "R2 Payload Key",
|
||||
"payload": "Payload",
|
||||
"receipt": "Receipt (EAR)",
|
||||
"receiptId": "Receipt ID",
|
||||
"r2ReceiptKey": "R2 Receipt Key"
|
||||
},
|
||||
"operations": {
|
||||
"title": "Operations",
|
||||
"subtitle": "Browse and inspect recorded operations",
|
||||
"searchPlaceholder": "Search by ID, agent, or type...",
|
||||
"colOperationId": "Operation ID",
|
||||
"colAgent": "Agent",
|
||||
"colType": "Type",
|
||||
"colIssuedAt": "Issued At",
|
||||
"colSeq": "Seq",
|
||||
"agentIdFilter": "Agent ID",
|
||||
"filterByAgent": "Filter by agent",
|
||||
"typeFilter": "Type",
|
||||
"filterByType": "Filter by type",
|
||||
"emptyMessage": "No operations found matching your filters.",
|
||||
"failedToLoad": "Failed to load operations."
|
||||
},
|
||||
"epochs": {
|
||||
"title": "Epochs",
|
||||
"subtitle": "Periodic Merkle-tree rollups anchoring operations",
|
||||
"colEpochId": "Epoch ID",
|
||||
"colTimeRange": "Time Range",
|
||||
"colLeaves": "Leaves",
|
||||
"colRootHash": "Root Hash",
|
||||
"colCreated": "Created",
|
||||
"emptyMessage": "No epochs have been created yet. Epochs are generated periodically as operations are recorded.",
|
||||
"failedToLoad": "Failed to load epochs."
|
||||
},
|
||||
"epochDetail": {
|
||||
"title": "Epoch Details",
|
||||
"viewOperations": "View Operations",
|
||||
"epochMetadata": "Epoch Metadata",
|
||||
"epochId": "Epoch ID",
|
||||
"organization": "Organization",
|
||||
"startTime": "Start Time",
|
||||
"endTime": "End Time",
|
||||
"duration": "Duration",
|
||||
"durationMinutes": "{{count}} minutes",
|
||||
"createdAt": "Created At",
|
||||
"merkleTree": "Merkle Tree",
|
||||
"rootHash": "Root Hash",
|
||||
"leafCount": "Leaf Count",
|
||||
"operationsUnit": "operations",
|
||||
"tsaAnchor": "TSA Anchor",
|
||||
"tsaToken": "TSA Token",
|
||||
"tsaUrl": "TSA URL",
|
||||
"anchoredAt": "Anchored At",
|
||||
"noTsaAnchor": "No TSA anchor information available for this epoch.",
|
||||
"storage": "Storage",
|
||||
"r2EpochKey": "R2 Epoch Key",
|
||||
"failedToLoad": "Failed to load epoch.",
|
||||
"mayNotExist": "The epoch may not exist."
|
||||
},
|
||||
"audit": {
|
||||
"title": "Audit Trail",
|
||||
"subtitle": "Query and inspect the full audit log of operations",
|
||||
"exportData": "Export Data",
|
||||
"queryBuilder": "Query Builder",
|
||||
"clearFilters": "Clear Filters",
|
||||
"agentId": "Agent ID",
|
||||
"filterByAgent": "Filter by agent",
|
||||
"operationType": "Operation Type",
|
||||
"filterByType": "Filter by type",
|
||||
"startTime": "Start Time",
|
||||
"endTime": "End Time",
|
||||
"showing": "Showing {{shown}} of {{total}} results",
|
||||
"moreResults": "More results available",
|
||||
"colOperationId": "Operation ID",
|
||||
"colAgent": "Agent",
|
||||
"colType": "Type",
|
||||
"colIssuedAt": "Issued At",
|
||||
"colSeq": "Seq",
|
||||
"colChainHash": "Chain Hash",
|
||||
"emptyMessage": "No audit records match your query. Adjust your filters or clear them to see all records.",
|
||||
"failedToLoad": "Failed to load audit data."
|
||||
},
|
||||
"exports": {
|
||||
"title": "Exports",
|
||||
"subtitle": "Create and download compliance data exports",
|
||||
"createExport": "Create Export",
|
||||
"colExportId": "Export ID",
|
||||
"colStatus": "Status",
|
||||
"colFilters": "Filters",
|
||||
"colCreated": "Created",
|
||||
"colCompleted": "Completed",
|
||||
"emptyMessage": "No exports have been created yet. Create an export to download compliance data.",
|
||||
"failedToLoad": "Failed to load exports.",
|
||||
"startTime": "Start Time",
|
||||
"endTime": "End Time",
|
||||
"agentIdOptional": "Agent ID (optional)",
|
||||
"filterByAgent": "Filter by specific agent",
|
||||
"operationTypeOptional": "Operation Type (optional)",
|
||||
"filterByOperationType": "Filter by operation type",
|
||||
"format": "Format",
|
||||
"creating": "Creating...",
|
||||
"startEndRequired": "Start time and end time are required.",
|
||||
"failedToCreate": "Failed to create export."
|
||||
},
|
||||
"jwks": {
|
||||
"title": "JWKS",
|
||||
"subtitle": "JSON Web Key Set for token verification",
|
||||
"publicKeys": "Public Keys",
|
||||
"colKeyId": "Key ID",
|
||||
"colKeyType": "Key Type",
|
||||
"colAlgorithm": "Algorithm",
|
||||
"colCurve": "Curve",
|
||||
"colUse": "Use",
|
||||
"colPublicKey": "Public Key",
|
||||
"emptyMessage": "No JWKS keys available. The server may not have published any public keys.",
|
||||
"failedToLoad": "Failed to load JWKS.",
|
||||
"endpoint": "JWKS endpoint:"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"subtitle": "Account and organization details",
|
||||
"user": "User",
|
||||
"displayName": "Display Name",
|
||||
"email": "Email",
|
||||
"subject": "Subject",
|
||||
"role": "Role",
|
||||
"organization": "Organization",
|
||||
"organizationId": "Organization ID"
|
||||
},
|
||||
"login": {
|
||||
"signIn": "Sign in",
|
||||
"email": "Email",
|
||||
"emailPlaceholder": "you@company.com",
|
||||
"password": "Password",
|
||||
"signingIn": "Signing in...",
|
||||
"submitSignIn": "Sign In",
|
||||
"noAccount": "No account? ",
|
||||
"createOne": "Create one",
|
||||
"loginFailed": "Login failed"
|
||||
},
|
||||
"register": {
|
||||
"createAccount": "Create account",
|
||||
"orgName": "Organization Name",
|
||||
"orgPlaceholder": "Acme Corp",
|
||||
"displayName": "Display Name",
|
||||
"displayNamePlaceholder": "Jane Smith",
|
||||
"email": "Email",
|
||||
"emailPlaceholder": "you@company.com",
|
||||
"password": "Password",
|
||||
"passwordPlaceholder": "Minimum 8 characters",
|
||||
"creatingAccount": "Creating account...",
|
||||
"submitCreateAccount": "Create Account",
|
||||
"hasAccount": "Already have an account? ",
|
||||
"signIn": "Sign in",
|
||||
"registrationFailed": "Registration failed"
|
||||
},
|
||||
"agentRegistration": {
|
||||
"displayName": "Display Name",
|
||||
"displayNamePlaceholder": "My AI Agent",
|
||||
"displayNameRequired": "Display Name is required.",
|
||||
"responsibleEntity": "Responsible Entity (optional)",
|
||||
"responsibleEntityPlaceholder": "Person or team responsible",
|
||||
"submitting": "Registering...",
|
||||
"submit": "Register Agent",
|
||||
"failedToRegister": "Failed to register agent.",
|
||||
"agentRegistered": "Agent Registered",
|
||||
"privateKeySaveNow": "Private Key — Save This Now",
|
||||
"privateKeyWarning": "This will not be shown again. Store it as an environment variable or in a secrets manager.",
|
||||
"apiToken": "API Token",
|
||||
"apiTokenDesc": "Use this as your ELYDORA_TOKEN environment variable. This is your bearer token for API authentication.",
|
||||
"tokenExpiration": "Token Expiration",
|
||||
"customDays": "Number of days",
|
||||
"days": "days",
|
||||
"enterValidDays": "Enter a valid number of days (minimum 1).",
|
||||
"issuing": "Issuing...",
|
||||
"issueApiToken": "Issue API Token",
|
||||
"failedToIssueToken": "Failed to issue token.",
|
||||
"credAgentId": "Agent ID",
|
||||
"credKeyId": "Key ID",
|
||||
"credOrgId": "Org ID",
|
||||
"credAlgorithm": "Algorithm",
|
||||
"chooseIntegration": "Choose Integration",
|
||||
"chooseIntegrationDesc": "How will this agent connect to Elydora?",
|
||||
"supported": "Supported (one-command install)",
|
||||
"sdkIntegration": "SDK Integration",
|
||||
"install": "Install",
|
||||
"backToList": "Back",
|
||||
"sdkTutorial": "SDK Tutorial",
|
||||
"aiReadyPrompt": "AI-Ready Prompt",
|
||||
"aiReadyPromptDesc": "Copy this prompt and paste it into your AI agent to set up Elydora automatically.",
|
||||
"copyPrompt": "Copy Prompt",
|
||||
"done": "Done",
|
||||
"step": "Step {{current}} of {{total}}"
|
||||
},
|
||||
"chain": {
|
||||
"title": "Chain Visualization",
|
||||
"previous": "Previous",
|
||||
"current": "Current ({{id}})",
|
||||
"next": "Next",
|
||||
"pending": "Pending...",
|
||||
"chainBlock": "Chain block",
|
||||
"currentOperation": "Current operation"
|
||||
},
|
||||
"verification": {
|
||||
"title": "Verification",
|
||||
"verify": "Verify",
|
||||
"reVerify": "Re-verify",
|
||||
"verifying": "Verifying...",
|
||||
"verifiedSuccess": "Operation verified successfully",
|
||||
"verificationFailed": "Verification failed",
|
||||
"signature": "Signature",
|
||||
"signatureDesc": "Ed25519 signature is valid for the given public key",
|
||||
"chainCheck": "Chain",
|
||||
"chainCheckDesc": "Previous chain hash matches the expected value",
|
||||
"receiptCheck": "Receipt",
|
||||
"receiptCheckDesc": "Server receipt (EAR) is valid and matches the operation",
|
||||
"merkle": "Merkle",
|
||||
"merkleDesc": "Operation is included in the epoch Merkle tree",
|
||||
"pending": "Pending",
|
||||
"passed": "Passed",
|
||||
"failed": "Failed",
|
||||
"errors": "Errors"
|
||||
},
|
||||
"tokenExpiration": {
|
||||
"24hours": "24 hours",
|
||||
"7days": "7 days",
|
||||
"1month": "1 month",
|
||||
"1year": "1 year",
|
||||
"custom": "Custom",
|
||||
"neverExpire": "Never expire"
|
||||
},
|
||||
"integrations": {
|
||||
"claudecode": "Anthropic CLI agent",
|
||||
"cursor": "AI-powered code editor",
|
||||
"gemini": "Google AI CLI agent",
|
||||
"kirocli": "AWS AI CLI agent",
|
||||
"kiroide": "AWS spec-driven AI IDE",
|
||||
"opencode": "Open-source TUI agent",
|
||||
"copilot": "GitHub Copilot CLI agent",
|
||||
"letta": "Letta AI CLI agent",
|
||||
"codex": "OpenAI CLI agent",
|
||||
"kimi": "Moonshot AI assistant",
|
||||
"enterprise": "Your own internal agent",
|
||||
"gui": "Desktop or web-based agent",
|
||||
"other": "Any custom integration"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
{
|
||||
"common": {
|
||||
"dashboard": "仪表盘",
|
||||
"agents": "代理",
|
||||
"operations": "操作",
|
||||
"epochs": "纪元",
|
||||
"auditTrail": "审计追踪",
|
||||
"exports": "导出",
|
||||
"jwks": "JWKS",
|
||||
"settings": "设置",
|
||||
"cancel": "取消",
|
||||
"delete": "删除",
|
||||
"download": "下载",
|
||||
"continue": "继续",
|
||||
"signOut": "退出登录",
|
||||
"copy": "复制",
|
||||
"copied": "已复制",
|
||||
"clickToCopy": "点击复制",
|
||||
"viewAll": "查看全部",
|
||||
"home": "首页",
|
||||
"docs": "文档",
|
||||
"console": "控制台",
|
||||
"loading": "加载中...",
|
||||
"unknownError": "未知错误。",
|
||||
"allData": "全部数据",
|
||||
"copyToClipboard": "复制到剪贴板",
|
||||
"copyJwks": "复制 JWKS",
|
||||
"links": "链接"
|
||||
},
|
||||
"sidebar": {
|
||||
"overview": "概览",
|
||||
"management": "管理",
|
||||
"compliance": "合规",
|
||||
"system": "系统"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "仪表盘",
|
||||
"subtitle": "Elydora 责任追溯控制台概览",
|
||||
"totalAgents": "代理总数",
|
||||
"registeredAgents": "已注册代理",
|
||||
"totalOperations": "操作总数",
|
||||
"allRecordedOps": "所有已记录操作",
|
||||
"activeEpochs": "活跃纪元",
|
||||
"merkleRollups": "Merkle 树汇总纪元",
|
||||
"pendingExports": "待处理导出",
|
||||
"queuedOrRunning": "排队中或运行中",
|
||||
"quickActions": "快捷操作",
|
||||
"registerAgent": "注册代理",
|
||||
"viewOperations": "查看操作",
|
||||
"createExport": "创建导出",
|
||||
"recentOperations": "最近操作",
|
||||
"noOperations": "尚无操作记录。",
|
||||
"agentSummary": "代理概要",
|
||||
"noAgentsRegistered": "尚未注册代理。注册一个代理以开始记录操作。",
|
||||
"latestActivity": "最近活动",
|
||||
"failedToLoad": "加载仪表盘数据失败。请确保 API 可访问。",
|
||||
"colOperationId": "操作 ID",
|
||||
"colAgent": "代理",
|
||||
"colType": "类型",
|
||||
"colIssued": "签发时间",
|
||||
"colSeq": "序号"
|
||||
},
|
||||
"agents": {
|
||||
"title": "代理",
|
||||
"subtitle": "管理已注册的 AI 代理",
|
||||
"registerAgent": "注册代理",
|
||||
"searchPlaceholder": "按 ID 搜索代理...",
|
||||
"colAgent": "代理",
|
||||
"colStatus": "状态",
|
||||
"colOperations": "操作数",
|
||||
"colLatestActivity": "最近活动",
|
||||
"emptyMessage": "未找到代理。注册一个代理以开始使用。",
|
||||
"failedToLoad": "加载代理列表失败。",
|
||||
"deleteAgent": "删除代理",
|
||||
"deleteConfirmPre": "确定要删除代理 ",
|
||||
"deleteConfirmPost": " 吗?这将永久移除该代理及其所有密钥。此操作无法撤销。",
|
||||
"deleting": "删除中...",
|
||||
"failedToDelete": "删除代理失败。"
|
||||
},
|
||||
"agentDetail": {
|
||||
"title": "代理详情",
|
||||
"agentId": "代理 ID",
|
||||
"status": "状态",
|
||||
"organization": "组织",
|
||||
"displayName": "显示名称",
|
||||
"responsibleEntity": "责任实体",
|
||||
"created": "创建时间",
|
||||
"keys": "密钥",
|
||||
"noKeys": "该代理没有已注册的密钥。",
|
||||
"colKeyId": "密钥 ID",
|
||||
"colAlgorithm": "算法",
|
||||
"colStatus": "状态",
|
||||
"colCreated": "创建时间",
|
||||
"revoke": "吊销",
|
||||
"operations": "操作",
|
||||
"freezeAgent": "冻结代理",
|
||||
"unfreezeAgent": "解冻代理",
|
||||
"reason": "原因",
|
||||
"freezePlaceholder": "请提供冻结此代理的原因...",
|
||||
"unfreezePlaceholder": "请提供解冻此代理的原因...",
|
||||
"freezing": "冻结中...",
|
||||
"confirmFreeze": "确认冻结",
|
||||
"unfreezing": "解冻中...",
|
||||
"confirmUnfreeze": "确认解冻",
|
||||
"failedToFreeze": "冻结代理失败。",
|
||||
"failedToUnfreeze": "解冻代理失败。",
|
||||
"reasonRequired": "原因为必填项。",
|
||||
"revokeKey": "吊销密钥",
|
||||
"revokePlaceholder": "请提供吊销此密钥的原因...",
|
||||
"revoking": "吊销中...",
|
||||
"confirmRevoke": "确认吊销",
|
||||
"failedToRevoke": "吊销密钥失败。",
|
||||
"freezeDescPre": "冻结代理 ",
|
||||
"freezeDescPost": " 将阻止其提交新操作。此操作会记录在审计追踪中。",
|
||||
"unfreezeDescPre": "解冻代理 ",
|
||||
"unfreezeDescPost": " 将允许其恢复提交操作。此操作会记录在审计追踪中。",
|
||||
"revokeKeyDescPre": "吊销密钥 ",
|
||||
"revokeKeyDescPost": " 将阻止其用于签署操作。此操作无法撤销。",
|
||||
"failedToLoad": "加载代理信息失败。",
|
||||
"mayNotExist": "该代理可能不存在。",
|
||||
"noOperations": "该代理尚无操作记录。"
|
||||
},
|
||||
"operationDetail": {
|
||||
"title": "操作详情",
|
||||
"failedToLoad": "加载操作信息失败。",
|
||||
"mayNotExist": "该操作可能不存在。"
|
||||
},
|
||||
"operationCard": {
|
||||
"operationRecord": "操作记录",
|
||||
"formatted": "格式化",
|
||||
"rawJson": "原始 JSON",
|
||||
"operationId": "操作 ID",
|
||||
"agentId": "代理 ID",
|
||||
"orgId": "组织 ID",
|
||||
"type": "类型",
|
||||
"issuedAt": "签发时间",
|
||||
"createdAt": "创建时间",
|
||||
"ttlMs": "TTL (ms)",
|
||||
"nonce": "Nonce",
|
||||
"seqNo": "序号",
|
||||
"signingKey": "签名密钥",
|
||||
"payloadHash": "载荷哈希",
|
||||
"prevChainHash": "上一链哈希",
|
||||
"chainHash": "链哈希",
|
||||
"r2PayloadKey": "R2 载荷键",
|
||||
"payload": "载荷",
|
||||
"receipt": "回执 (EAR)",
|
||||
"receiptId": "回执 ID",
|
||||
"r2ReceiptKey": "R2 回执键"
|
||||
},
|
||||
"operations": {
|
||||
"title": "操作",
|
||||
"subtitle": "浏览和检查已记录的操作",
|
||||
"searchPlaceholder": "按 ID、代理或类型搜索...",
|
||||
"colOperationId": "操作 ID",
|
||||
"colAgent": "代理",
|
||||
"colType": "类型",
|
||||
"colIssuedAt": "签发时间",
|
||||
"colSeq": "序号",
|
||||
"agentIdFilter": "代理 ID",
|
||||
"filterByAgent": "按代理筛选",
|
||||
"typeFilter": "类型",
|
||||
"filterByType": "按类型筛选",
|
||||
"emptyMessage": "没有符合筛选条件的操作。",
|
||||
"failedToLoad": "加载操作列表失败。"
|
||||
},
|
||||
"epochs": {
|
||||
"title": "纪元",
|
||||
"subtitle": "定期 Merkle 树汇总锚定操作",
|
||||
"colEpochId": "纪元 ID",
|
||||
"colTimeRange": "时间范围",
|
||||
"colLeaves": "叶子数",
|
||||
"colRootHash": "根哈希",
|
||||
"colCreated": "创建时间",
|
||||
"emptyMessage": "尚未创建纪元。纪元会随着操作的记录定期生成。",
|
||||
"failedToLoad": "加载纪元列表失败。"
|
||||
},
|
||||
"epochDetail": {
|
||||
"title": "纪元详情",
|
||||
"viewOperations": "查看操作",
|
||||
"epochMetadata": "纪元元数据",
|
||||
"epochId": "纪元 ID",
|
||||
"organization": "组织",
|
||||
"startTime": "开始时间",
|
||||
"endTime": "结束时间",
|
||||
"duration": "持续时间",
|
||||
"durationMinutes": "{{count}} 分钟",
|
||||
"createdAt": "创建时间",
|
||||
"merkleTree": "Merkle 树",
|
||||
"rootHash": "根哈希",
|
||||
"leafCount": "叶子数量",
|
||||
"operationsUnit": "个操作",
|
||||
"tsaAnchor": "TSA 锚点",
|
||||
"tsaToken": "TSA 令牌",
|
||||
"tsaUrl": "TSA URL",
|
||||
"anchoredAt": "锚定时间",
|
||||
"noTsaAnchor": "此纪元暂无 TSA 锚点信息。",
|
||||
"storage": "存储",
|
||||
"r2EpochKey": "R2 纪元键",
|
||||
"failedToLoad": "加载纪元信息失败。",
|
||||
"mayNotExist": "该纪元可能不存在。"
|
||||
},
|
||||
"audit": {
|
||||
"title": "审计追踪",
|
||||
"subtitle": "查询和检查完整的操作审计日志",
|
||||
"exportData": "导出数据",
|
||||
"queryBuilder": "查询构建器",
|
||||
"clearFilters": "清除筛选",
|
||||
"agentId": "代理 ID",
|
||||
"filterByAgent": "按代理筛选",
|
||||
"operationType": "操作类型",
|
||||
"filterByType": "按类型筛选",
|
||||
"startTime": "开始时间",
|
||||
"endTime": "结束时间",
|
||||
"showing": "显示 {{shown}} / {{total}} 条结果",
|
||||
"moreResults": "还有更多结果",
|
||||
"colOperationId": "操作 ID",
|
||||
"colAgent": "代理",
|
||||
"colType": "类型",
|
||||
"colIssuedAt": "签发时间",
|
||||
"colSeq": "序号",
|
||||
"colChainHash": "链哈希",
|
||||
"emptyMessage": "没有匹配的审计记录。请调整筛选条件或清除筛选以查看所有记录。",
|
||||
"failedToLoad": "加载审计数据失败。"
|
||||
},
|
||||
"exports": {
|
||||
"title": "导出",
|
||||
"subtitle": "创建和下载合规数据导出",
|
||||
"createExport": "创建导出",
|
||||
"colExportId": "导出 ID",
|
||||
"colStatus": "状态",
|
||||
"colFilters": "筛选条件",
|
||||
"colCreated": "创建时间",
|
||||
"colCompleted": "完成时间",
|
||||
"emptyMessage": "尚未创建导出。创建导出以下载合规数据。",
|
||||
"failedToLoad": "加载导出列表失败。",
|
||||
"startTime": "开始时间",
|
||||
"endTime": "结束时间",
|
||||
"agentIdOptional": "代理 ID(可选)",
|
||||
"filterByAgent": "按特定代理筛选",
|
||||
"operationTypeOptional": "操作类型(可选)",
|
||||
"filterByOperationType": "按操作类型筛选",
|
||||
"format": "格式",
|
||||
"creating": "创建中...",
|
||||
"startEndRequired": "开始时间和结束时间为必填项。",
|
||||
"failedToCreate": "创建导出失败。"
|
||||
},
|
||||
"jwks": {
|
||||
"title": "JWKS",
|
||||
"subtitle": "用于令牌验证的 JSON Web 密钥集",
|
||||
"publicKeys": "公钥",
|
||||
"colKeyId": "密钥 ID",
|
||||
"colKeyType": "密钥类型",
|
||||
"colAlgorithm": "算法",
|
||||
"colCurve": "曲线",
|
||||
"colUse": "用途",
|
||||
"colPublicKey": "公钥",
|
||||
"emptyMessage": "没有可用的 JWKS 密钥。服务器可能尚未发布公钥。",
|
||||
"failedToLoad": "加载 JWKS 失败。",
|
||||
"endpoint": "JWKS 端点:"
|
||||
},
|
||||
"settings": {
|
||||
"title": "设置",
|
||||
"subtitle": "账户和组织详情",
|
||||
"user": "用户",
|
||||
"displayName": "显示名称",
|
||||
"email": "邮箱",
|
||||
"subject": "主体",
|
||||
"role": "角色",
|
||||
"organization": "组织",
|
||||
"organizationId": "组织 ID"
|
||||
},
|
||||
"login": {
|
||||
"signIn": "登录",
|
||||
"email": "邮箱",
|
||||
"emailPlaceholder": "you@company.com",
|
||||
"password": "密码",
|
||||
"signingIn": "登录中...",
|
||||
"submitSignIn": "登录",
|
||||
"noAccount": "还没有账户?",
|
||||
"createOne": "创建一个",
|
||||
"loginFailed": "登录失败"
|
||||
},
|
||||
"register": {
|
||||
"createAccount": "创建账户",
|
||||
"orgName": "组织名称",
|
||||
"orgPlaceholder": "示例公司",
|
||||
"displayName": "显示名称",
|
||||
"displayNamePlaceholder": "张三",
|
||||
"email": "邮箱",
|
||||
"emailPlaceholder": "you@company.com",
|
||||
"password": "密码",
|
||||
"passwordPlaceholder": "至少 8 个字符",
|
||||
"creatingAccount": "创建账户中...",
|
||||
"submitCreateAccount": "创建账户",
|
||||
"hasAccount": "已有账户?",
|
||||
"signIn": "登录",
|
||||
"registrationFailed": "注册失败"
|
||||
},
|
||||
"agentRegistration": {
|
||||
"displayName": "显示名称",
|
||||
"displayNamePlaceholder": "我的 AI 代理",
|
||||
"displayNameRequired": "显示名称为必填项。",
|
||||
"responsibleEntity": "责任实体(可选)",
|
||||
"responsibleEntityPlaceholder": "负责人或团队",
|
||||
"submitting": "注册中...",
|
||||
"submit": "注册代理",
|
||||
"failedToRegister": "注册代理失败。",
|
||||
"agentRegistered": "代理注册成功",
|
||||
"privateKeySaveNow": "私钥 — 请立即保存",
|
||||
"privateKeyWarning": "此私钥不会再次显示。请将其存储为环境变量或保存在密钥管理器中。",
|
||||
"apiToken": "API 令牌",
|
||||
"apiTokenDesc": "将其用作 ELYDORA_TOKEN 环境变量。这是您的 API 认证令牌。",
|
||||
"tokenExpiration": "令牌有效期",
|
||||
"customDays": "天数",
|
||||
"days": "天",
|
||||
"enterValidDays": "请输入有效的天数(最少 1 天)。",
|
||||
"issuing": "签发中...",
|
||||
"issueApiToken": "签发 API 令牌",
|
||||
"failedToIssueToken": "签发令牌失败。",
|
||||
"credAgentId": "代理 ID",
|
||||
"credKeyId": "密钥 ID",
|
||||
"credOrgId": "组织 ID",
|
||||
"credAlgorithm": "算法",
|
||||
"chooseIntegration": "选择集成方式",
|
||||
"chooseIntegrationDesc": "此代理将如何连接到 Elydora?",
|
||||
"supported": "支持的(一键安装)",
|
||||
"sdkIntegration": "SDK 集成",
|
||||
"install": "安装",
|
||||
"backToList": "返回",
|
||||
"sdkTutorial": "SDK 教程",
|
||||
"aiReadyPrompt": "AI 就绪提示",
|
||||
"aiReadyPromptDesc": "复制此提示并粘贴到您的 AI 代理中以自动设置 Elydora。",
|
||||
"copyPrompt": "复制提示",
|
||||
"done": "完成",
|
||||
"step": "第 {{current}} 步,共 {{total}} 步"
|
||||
},
|
||||
"chain": {
|
||||
"title": "链可视化",
|
||||
"previous": "上一个",
|
||||
"current": "当前 ({{id}})",
|
||||
"next": "下一个",
|
||||
"pending": "待处理...",
|
||||
"chainBlock": "链块",
|
||||
"currentOperation": "当前操作"
|
||||
},
|
||||
"verification": {
|
||||
"title": "验证",
|
||||
"verify": "验证",
|
||||
"reVerify": "重新验证",
|
||||
"verifying": "验证中...",
|
||||
"verifiedSuccess": "操作验证成功",
|
||||
"verificationFailed": "验证失败",
|
||||
"signature": "签名",
|
||||
"signatureDesc": "Ed25519 签名对给定公钥有效",
|
||||
"chainCheck": "链",
|
||||
"chainCheckDesc": "上一链哈希与预期值匹配",
|
||||
"receiptCheck": "回执",
|
||||
"receiptCheckDesc": "服务器回执 (EAR) 有效且与操作匹配",
|
||||
"merkle": "Merkle",
|
||||
"merkleDesc": "操作包含在纪元 Merkle 树中",
|
||||
"pending": "待验证",
|
||||
"passed": "通过",
|
||||
"failed": "失败",
|
||||
"errors": "错误"
|
||||
},
|
||||
"tokenExpiration": {
|
||||
"24hours": "24 小时",
|
||||
"7days": "7 天",
|
||||
"1month": "1 个月",
|
||||
"1year": "1 年",
|
||||
"custom": "自定义",
|
||||
"neverExpire": "永不过期"
|
||||
},
|
||||
"integrations": {
|
||||
"claudecode": "Anthropic CLI 代理",
|
||||
"cursor": "AI 驱动的代码编辑器",
|
||||
"gemini": "Google AI CLI 代理",
|
||||
"kirocli": "AWS AI CLI 代理",
|
||||
"kiroide": "AWS 规格驱动的 AI IDE",
|
||||
"opencode": "开源 TUI 代理",
|
||||
"copilot": "GitHub Copilot CLI 代理",
|
||||
"letta": "Letta AI CLI 代理",
|
||||
"codex": "OpenAI CLI 代理",
|
||||
"kimi": "Moonshot AI 助手",
|
||||
"enterprise": "您的内部代理",
|
||||
"gui": "桌面或 Web 代理",
|
||||
"other": "任何自定义集成"
|
||||
}
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.Iterable",
|
||||
"ESNext"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"noEmit": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@elydora/shared": [
|
||||
"../server/src/shared"
|
||||
],
|
||||
"@elydora/shared/*": [
|
||||
"../server/src/shared/*"
|
||||
]
|
||||
},
|
||||
"allowJs": true
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN addgroup --system --gid 1001 elydora && adduser --system --uid 1001 elydora
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/migrations ./migrations
|
||||
USER elydora
|
||||
EXPOSE 8787
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -0,0 +1,161 @@
|
||||
-- Elydora PostgreSQL Schema
|
||||
-- Migration 001: Combined initial schema (agents, auth, operations, epochs, exports)
|
||||
-- All timestamps are Unix milliseconds (BIGINT)
|
||||
|
||||
-- =========================================================================
|
||||
-- Schema version tracking
|
||||
-- =========================================================================
|
||||
CREATE TABLE IF NOT EXISTS schema_versions (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at BIGINT NOT NULL,
|
||||
description TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- =========================================================================
|
||||
-- Organizations
|
||||
-- =========================================================================
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
org_id TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
-- =========================================================================
|
||||
-- Users
|
||||
-- =========================================================================
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id TEXT NOT NULL PRIMARY KEY,
|
||||
org_id TEXT NOT NULL REFERENCES organizations (org_id),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'org_owner',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_org_id ON users (org_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
|
||||
|
||||
-- =========================================================================
|
||||
-- Agents
|
||||
-- =========================================================================
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
agent_id TEXT NOT NULL PRIMARY KEY,
|
||||
org_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
responsible_entity TEXT NOT NULL,
|
||||
integration_type TEXT NOT NULL DEFAULT 'sdk',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agents_org_id ON agents (org_id);
|
||||
|
||||
-- =========================================================================
|
||||
-- Agent keys
|
||||
-- =========================================================================
|
||||
CREATE TABLE IF NOT EXISTS agent_keys (
|
||||
kid TEXT NOT NULL PRIMARY KEY,
|
||||
agent_id TEXT NOT NULL REFERENCES agents (agent_id),
|
||||
public_key TEXT NOT NULL,
|
||||
algorithm TEXT NOT NULL DEFAULT 'ed25519',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at BIGINT NOT NULL,
|
||||
retired_at BIGINT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_keys_agent_id ON agent_keys (agent_id);
|
||||
|
||||
-- =========================================================================
|
||||
-- Operations
|
||||
-- =========================================================================
|
||||
CREATE TABLE IF NOT EXISTS operations (
|
||||
operation_id TEXT NOT NULL PRIMARY KEY,
|
||||
org_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL REFERENCES agents (agent_id),
|
||||
seq_no INTEGER NOT NULL,
|
||||
operation_type TEXT NOT NULL,
|
||||
issued_at BIGINT NOT NULL,
|
||||
ttl_ms INTEGER NOT NULL,
|
||||
nonce TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
payload_hash TEXT NOT NULL,
|
||||
prev_chain_hash TEXT NOT NULL,
|
||||
chain_hash TEXT NOT NULL,
|
||||
agent_pubkey_kid TEXT NOT NULL,
|
||||
signature TEXT NOT NULL,
|
||||
r2_payload_key TEXT,
|
||||
created_at BIGINT NOT NULL,
|
||||
UNIQUE (agent_id, seq_no)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_operations_org_created ON operations (org_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_operations_agent_seq ON operations (agent_id, seq_no);
|
||||
CREATE INDEX IF NOT EXISTS idx_operations_type ON operations (operation_type);
|
||||
|
||||
-- =========================================================================
|
||||
-- Receipts
|
||||
-- =========================================================================
|
||||
CREATE TABLE IF NOT EXISTS receipts (
|
||||
receipt_id TEXT NOT NULL PRIMARY KEY,
|
||||
operation_id TEXT NOT NULL UNIQUE REFERENCES operations (operation_id),
|
||||
r2_receipt_key TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
-- =========================================================================
|
||||
-- Epochs
|
||||
-- =========================================================================
|
||||
CREATE TABLE IF NOT EXISTS epochs (
|
||||
epoch_id TEXT NOT NULL PRIMARY KEY,
|
||||
org_id TEXT NOT NULL,
|
||||
start_time BIGINT NOT NULL,
|
||||
end_time BIGINT NOT NULL,
|
||||
root_hash TEXT NOT NULL,
|
||||
leaf_count INTEGER NOT NULL,
|
||||
r2_epoch_key TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
-- =========================================================================
|
||||
-- Admin events
|
||||
-- =========================================================================
|
||||
CREATE TABLE IF NOT EXISTS admin_events (
|
||||
event_id TEXT NOT NULL PRIMARY KEY,
|
||||
org_id TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
details TEXT,
|
||||
created_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_admin_events_org_created ON admin_events (org_id, created_at);
|
||||
|
||||
-- =========================================================================
|
||||
-- Exports
|
||||
-- =========================================================================
|
||||
CREATE TABLE IF NOT EXISTS exports (
|
||||
export_id TEXT NOT NULL PRIMARY KEY,
|
||||
org_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
query_params TEXT NOT NULL,
|
||||
r2_export_key TEXT,
|
||||
created_at BIGINT NOT NULL,
|
||||
completed_at BIGINT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_exports_org_status ON exports (org_id, status);
|
||||
|
||||
-- =========================================================================
|
||||
-- Seed schema version
|
||||
-- =========================================================================
|
||||
INSERT INTO schema_versions (version, applied_at, description)
|
||||
VALUES (1, EXTRACT(EPOCH FROM NOW())::BIGINT * 1000, 'Initial schema (combined)')
|
||||
ON CONFLICT (version) DO NOTHING;
|
||||
Generated
+2785
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@elydora/server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"dev:worker": "tsx watch src/worker.ts",
|
||||
"dev:scheduler": "tsx watch src/scheduler.ts",
|
||||
"start": "node dist/index.js",
|
||||
"start:worker": "node dist/worker.js",
|
||||
"start:scheduler": "node dist/scheduler.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.0.0",
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"bullmq": "^5.0.0",
|
||||
"dotenv": "^16.0.0",
|
||||
"hono": "^4.7.0",
|
||||
"ioredis": "^5.3.0",
|
||||
"node-cron": "^3.0.0",
|
||||
"pg": "^8.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/node-cron": "^3.0.0",
|
||||
"@types/pg": "^8.11.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* BullMQ queue adapter implementing the MessageQueue interface.
|
||||
*
|
||||
* Wraps a BullMQ Queue instance to replace Cloudflare's Queue producer.
|
||||
* Jobs are enqueued with retry-on-failure semantics (3 attempts,
|
||||
* exponential back-off) matching the Cloudflare Queues default behaviour.
|
||||
*/
|
||||
|
||||
import { Queue as BullQueue } from 'bullmq';
|
||||
import type { ConnectionOptions } from 'bullmq';
|
||||
import type { MessageQueue } from './interfaces.js';
|
||||
import { generateUUIDv7 } from '../utils/uuid.js';
|
||||
|
||||
export const QUEUE_NAME = 'elydora-queue';
|
||||
|
||||
export class BullMQAdapter implements MessageQueue {
|
||||
private readonly queue: BullQueue;
|
||||
|
||||
constructor(connection: ConnectionOptions) {
|
||||
this.queue = new BullQueue(QUEUE_NAME, { connection });
|
||||
}
|
||||
|
||||
async send(body: unknown): Promise<{ messageId: string }> {
|
||||
const job = await this.queue.add('message', body as Record<string, unknown>, {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 1000 },
|
||||
removeOnComplete: { count: 100 },
|
||||
removeOnFail: { count: 500 },
|
||||
});
|
||||
return { messageId: job.id ?? generateUUIDv7() };
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.queue.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Adapter interfaces for ElydoraOpenSource infrastructure services.
|
||||
*
|
||||
* These interfaces mirror the shape of Cloudflare's D1, R2, KV, and Queue
|
||||
* APIs so that all existing service code can run unchanged against
|
||||
* PostgreSQL, MinIO, Redis, and BullMQ implementations.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Database (replaces D1Database)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A prepared (and optionally bound) SQL statement.
|
||||
* Mirrors D1PreparedStatement's interface exactly.
|
||||
*/
|
||||
export interface PreparedStatement {
|
||||
/**
|
||||
* Bind positional parameters (? placeholders) to the statement.
|
||||
* Returns a new statement with the values set.
|
||||
*/
|
||||
bind(...values: unknown[]): PreparedStatement;
|
||||
|
||||
/** Execute and return the first row, or null if no rows match. */
|
||||
first<T = unknown>(): Promise<T | null>;
|
||||
|
||||
/** Execute and return all matching rows. */
|
||||
all<T = unknown>(): Promise<{ results: T[] }>;
|
||||
|
||||
/** Execute a write statement (INSERT/UPDATE/DELETE). */
|
||||
run(): Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relational database adapter.
|
||||
* Mirrors the subset of D1Database methods used by Elydora services.
|
||||
*/
|
||||
export interface Database {
|
||||
/** Prepare a SQL statement for execution. */
|
||||
prepare(sql: string): PreparedStatement;
|
||||
|
||||
/**
|
||||
* Execute multiple prepared statements atomically (within a transaction).
|
||||
* Mirrors D1Database.batch().
|
||||
*/
|
||||
batch(statements: PreparedStatement[]): Promise<{ results: unknown[]; success: boolean }[]>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Object store (replaces R2Bucket)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ObjectStorePutOptions {
|
||||
httpMetadata?: { contentType?: string };
|
||||
customMetadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ObjectStoreObject {
|
||||
/** Readable body stream of the stored object. */
|
||||
readonly body: ReadableStream;
|
||||
/** Object size in bytes, if known. */
|
||||
readonly size?: number;
|
||||
/** HTTP metadata associated with the object. */
|
||||
readonly httpMetadata?: { readonly contentType?: string };
|
||||
/** Parse the body as JSON. */
|
||||
json<T = unknown>(): Promise<T>;
|
||||
/** Return the body as a UTF-8 string. */
|
||||
text(): Promise<string>;
|
||||
}
|
||||
|
||||
export interface ObjectStoreHead {
|
||||
readonly size?: number;
|
||||
readonly httpMetadata?: { readonly contentType?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Object/blob storage adapter.
|
||||
* Mirrors the subset of R2Bucket methods used by Elydora services.
|
||||
*/
|
||||
export interface ObjectStore {
|
||||
/** Upload an object to the store. */
|
||||
put(
|
||||
key: string,
|
||||
body: string | Uint8Array | ReadableStream,
|
||||
options?: ObjectStorePutOptions,
|
||||
): Promise<void>;
|
||||
|
||||
/** Retrieve an object, or null if not found. */
|
||||
get(key: string): Promise<ObjectStoreObject | null>;
|
||||
|
||||
/** Check if an object exists (metadata only). Returns null if not found. */
|
||||
head(key: string): Promise<ObjectStoreHead | null>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache (replaces KVNamespace)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Key/value cache adapter.
|
||||
* Mirrors the subset of KVNamespace methods used by Elydora services.
|
||||
*/
|
||||
export interface Cache {
|
||||
/** Retrieve a cached value, or null if not found / expired. */
|
||||
get(key: string): Promise<string | null>;
|
||||
|
||||
/** Store a value in the cache with an optional TTL in seconds. */
|
||||
put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message queue (replaces Queue)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Message queue adapter.
|
||||
* Mirrors the subset of Cloudflare Queue methods used by Elydora services.
|
||||
*/
|
||||
export interface MessageQueue {
|
||||
/** Enqueue a message. Returns the assigned message ID. */
|
||||
send(body: unknown): Promise<{ messageId: string }>;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* MinIO / S3-compatible object storage adapter implementing the ObjectStore interface.
|
||||
*
|
||||
* Uses the AWS SDK v3 S3 client configured with a custom endpoint for MinIO.
|
||||
* Responses are buffered in memory so that both streaming (body) and random
|
||||
* access (json/text) are available on the returned ObjectStoreObject.
|
||||
*/
|
||||
|
||||
import {
|
||||
S3Client,
|
||||
PutObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
type PutObjectCommandInput,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { Readable } from 'stream';
|
||||
import type {
|
||||
ObjectStore,
|
||||
ObjectStoreObject,
|
||||
ObjectStoreHead,
|
||||
ObjectStorePutOptions,
|
||||
} from './interfaces.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// S3 error detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isNoSuchKey(err: unknown): boolean {
|
||||
if (!err || typeof err !== 'object') return false;
|
||||
const code = (err as Record<string, unknown>).Code ?? (err as Record<string, unknown>).name;
|
||||
return code === 'NoSuchKey' || code === 'NotFound';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MinIO adapter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class MinioAdapter implements ObjectStore {
|
||||
private readonly client: S3Client;
|
||||
private readonly bucket: string;
|
||||
|
||||
constructor(endpoint: string, accessKey: string, secretKey: string, bucket: string) {
|
||||
this.client = new S3Client({
|
||||
endpoint,
|
||||
region: 'us-east-1', // required by S3 SDK, MinIO ignores it
|
||||
credentials: {
|
||||
accessKeyId: accessKey,
|
||||
secretAccessKey: secretKey,
|
||||
},
|
||||
forcePathStyle: true, // required for MinIO
|
||||
});
|
||||
this.bucket = bucket;
|
||||
}
|
||||
|
||||
async put(
|
||||
key: string,
|
||||
body: string | Uint8Array | ReadableStream,
|
||||
options?: ObjectStorePutOptions,
|
||||
): Promise<void> {
|
||||
let bodyData: Buffer | string | Uint8Array;
|
||||
|
||||
if (typeof body === 'string') {
|
||||
bodyData = body;
|
||||
} else if (body instanceof Uint8Array) {
|
||||
bodyData = body;
|
||||
} else {
|
||||
// Web ReadableStream — collect into buffer
|
||||
const chunks: Uint8Array[] = [];
|
||||
const reader = body.getReader();
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) chunks.push(value);
|
||||
}
|
||||
bodyData = Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
const params: PutObjectCommandInput = {
|
||||
Bucket: this.bucket,
|
||||
Key: key,
|
||||
Body: bodyData,
|
||||
};
|
||||
|
||||
if (options?.httpMetadata?.contentType) {
|
||||
params.ContentType = options.httpMetadata.contentType;
|
||||
}
|
||||
|
||||
if (options?.customMetadata) {
|
||||
params.Metadata = options.customMetadata;
|
||||
}
|
||||
|
||||
await this.client.send(new PutObjectCommand(params));
|
||||
}
|
||||
|
||||
async get(key: string): Promise<ObjectStoreObject | null> {
|
||||
try {
|
||||
const response = await this.client.send(
|
||||
new GetObjectCommand({ Bucket: this.bucket, Key: key }),
|
||||
);
|
||||
|
||||
if (!response.Body) return null;
|
||||
|
||||
// Buffer the entire response so that json(), text(), and body can all work
|
||||
const nodeReadable = response.Body as Readable;
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of nodeReadable) {
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
const buffer = Buffer.concat(chunks);
|
||||
const contentType = response.ContentType;
|
||||
const contentLength = response.ContentLength;
|
||||
|
||||
return {
|
||||
get body(): ReadableStream {
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(buffer);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
},
|
||||
size: contentLength,
|
||||
httpMetadata: contentType ? { contentType } : undefined,
|
||||
async json<T = unknown>(): Promise<T> {
|
||||
return JSON.parse(buffer.toString('utf-8')) as T;
|
||||
},
|
||||
async text(): Promise<string> {
|
||||
return buffer.toString('utf-8');
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
if (isNoSuchKey(err)) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async head(key: string): Promise<ObjectStoreHead | null> {
|
||||
try {
|
||||
const response = await this.client.send(
|
||||
new HeadObjectCommand({ Bucket: this.bucket, Key: key }),
|
||||
);
|
||||
return {
|
||||
size: response.ContentLength,
|
||||
httpMetadata: response.ContentType ? { contentType: response.ContentType } : undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
if (isNoSuchKey(err)) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* PostgreSQL adapter implementing the Database interface.
|
||||
*
|
||||
* Translates the D1-style API (prepare/bind/first/all/run/batch) into
|
||||
* standard `pg` Pool queries. SQLite `?` placeholders are converted to
|
||||
* PostgreSQL `$N` positional parameters automatically.
|
||||
*
|
||||
* Batch execution runs all statements inside a single transaction to
|
||||
* preserve the atomicity guarantee of D1.batch().
|
||||
*/
|
||||
|
||||
import pg from 'pg';
|
||||
import type { Database, PreparedStatement } from './interfaces.js';
|
||||
|
||||
const { Pool, types } = pg;
|
||||
type Pool = InstanceType<typeof pg.Pool>;
|
||||
type PoolClient = pg.PoolClient;
|
||||
|
||||
// Parse PostgreSQL BIGINT (oid 20) as JavaScript number instead of string.
|
||||
// Elydora timestamps are Unix milliseconds (~1.7e12) which fit safely within
|
||||
// Number.MAX_SAFE_INTEGER (~9e15).
|
||||
types.setTypeParser(20, (val: string) => Number(val));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Placeholder conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert SQLite `?` positional placeholders to PostgreSQL `$N` style.
|
||||
* Only replaces `?` outside of SQL string literals (safe for all Elydora queries).
|
||||
*/
|
||||
function convertPlaceholders(sql: string): string {
|
||||
let index = 0;
|
||||
return sql.replace(/\?/g, () => `$${++index}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Statement implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Internal PostgreSQL statement that implements PreparedStatement.
|
||||
* Stores the SQL and bound values; executes lazily on first/all/run/executeWith.
|
||||
*/
|
||||
class PostgresStatement implements PreparedStatement {
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
private readonly sql: string,
|
||||
private readonly values: unknown[],
|
||||
) {}
|
||||
|
||||
bind(...values: unknown[]): PreparedStatement {
|
||||
return new PostgresStatement(this.pool, this.sql, values);
|
||||
}
|
||||
|
||||
async first<T = unknown>(): Promise<T | null> {
|
||||
const result = await this.pool.query<Record<string, unknown>>({
|
||||
text: convertPlaceholders(this.sql),
|
||||
values: this.values as unknown[],
|
||||
});
|
||||
return (result.rows[0] as T) ?? null;
|
||||
}
|
||||
|
||||
async all<T = unknown>(): Promise<{ results: T[] }> {
|
||||
const result = await this.pool.query<Record<string, unknown>>({
|
||||
text: convertPlaceholders(this.sql),
|
||||
values: this.values as unknown[],
|
||||
});
|
||||
return { results: result.rows as T[] };
|
||||
}
|
||||
|
||||
async run(): Promise<{ success: boolean }> {
|
||||
await this.pool.query({
|
||||
text: convertPlaceholders(this.sql),
|
||||
values: this.values as unknown[],
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** Execute using a specific pooled client (used by batch transaction). */
|
||||
async executeWith(client: PoolClient): Promise<{ results: unknown[]; success: boolean }> {
|
||||
const result = await client.query({
|
||||
text: convertPlaceholders(this.sql),
|
||||
values: this.values as unknown[],
|
||||
});
|
||||
return { results: result.rows, success: true };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PostgreSQL adapter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class PostgresAdapter implements Database {
|
||||
private readonly pool: Pool;
|
||||
|
||||
constructor(connectionString: string) {
|
||||
this.pool = new Pool({ connectionString });
|
||||
}
|
||||
|
||||
prepare(sql: string): PreparedStatement {
|
||||
return new PostgresStatement(this.pool, sql, []);
|
||||
}
|
||||
|
||||
async batch(
|
||||
statements: PreparedStatement[],
|
||||
): Promise<{ results: unknown[]; success: boolean }[]> {
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const results: { results: unknown[]; success: boolean }[] = [];
|
||||
for (const stmt of statements) {
|
||||
const pgStmt = stmt as PostgresStatement;
|
||||
results.push(await pgStmt.executeWith(client));
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
return results;
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the connection pool (call on graceful shutdown). */
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Redis cache adapter implementing the Cache interface.
|
||||
*
|
||||
* Uses ioredis to back the nonce-deduplication and chain-hash caching
|
||||
* that was previously served by Cloudflare KVNamespace.
|
||||
*/
|
||||
|
||||
import { Redis } from 'ioredis';
|
||||
import type { Cache } from './interfaces.js';
|
||||
|
||||
export class RedisCacheAdapter implements Cache {
|
||||
private readonly client: Redis;
|
||||
|
||||
constructor(redisUrl: string) {
|
||||
this.client = new Redis(redisUrl, { lazyConnect: true });
|
||||
}
|
||||
|
||||
async get(key: string): Promise<string | null> {
|
||||
return this.client.get(key);
|
||||
}
|
||||
|
||||
async put(
|
||||
key: string,
|
||||
value: string,
|
||||
options?: { expirationTtl?: number },
|
||||
): Promise<void> {
|
||||
if (options?.expirationTtl) {
|
||||
await this.client.set(key, value, 'EX', options.expirationTtl);
|
||||
} else {
|
||||
await this.client.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
await this.client.connect();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.client.quit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
/**
|
||||
* Internationalization (i18n) translation map.
|
||||
*
|
||||
* Provides bilingual (English / Chinese) translations for all user-facing
|
||||
* error and validation messages returned by the Elydora API.
|
||||
*
|
||||
* Message keys follow a dot-separated convention:
|
||||
* <domain>.<specificMessage>
|
||||
*
|
||||
* Some messages accept parameters via simple {param} placeholders that
|
||||
* callers must replace before returning the message to the client.
|
||||
*/
|
||||
|
||||
export type Lang = 'en' | 'zh';
|
||||
|
||||
const messages: Record<string, Record<Lang, string>> = {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error code default messages (src/shared/constants/errors.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'error.invalidSignature': {
|
||||
en: 'The operation signature is invalid.',
|
||||
zh: '\u64cd\u4f5c\u7b7e\u540d\u65e0\u6548\u3002',
|
||||
},
|
||||
'error.unknownAgent': {
|
||||
en: 'The specified agent does not exist.',
|
||||
zh: '\u6307\u5b9a\u7684\u4ee3\u7406\u4e0d\u5b58\u5728\u3002',
|
||||
},
|
||||
'error.keyRevoked': {
|
||||
en: 'The signing key has been revoked.',
|
||||
zh: '\u7b7e\u540d\u5bc6\u94a5\u5df2\u88ab\u64a4\u9500\u3002',
|
||||
},
|
||||
'error.agentFrozen': {
|
||||
en: 'The agent is frozen and cannot submit operations.',
|
||||
zh: '\u8be5\u4ee3\u7406\u5df2\u88ab\u51bb\u7ed3\uff0c\u65e0\u6cd5\u63d0\u4ea4\u64cd\u4f5c\u3002',
|
||||
},
|
||||
'error.ttlExpired': {
|
||||
en: 'The operation TTL has expired.',
|
||||
zh: '\u64cd\u4f5c\u7684 TTL \u5df2\u8fc7\u671f\u3002',
|
||||
},
|
||||
'error.replayDetected': {
|
||||
en: 'A duplicate nonce was detected (replay attack).',
|
||||
zh: '\u68c0\u6d4b\u5230\u91cd\u590d\u7684\u968f\u673a\u6570\uff08\u91cd\u653e\u653b\u51fb\uff09\u3002',
|
||||
},
|
||||
'error.prevHashMismatch': {
|
||||
en: 'The previous chain hash does not match.',
|
||||
zh: '\u524d\u4e00\u4e2a\u94fe\u54c8\u5e0c\u4e0d\u5339\u914d\u3002',
|
||||
},
|
||||
'error.payloadTooLarge': {
|
||||
en: 'The operation payload exceeds the maximum allowed size.',
|
||||
zh: '\u64cd\u4f5c\u8d1f\u8f7d\u8d85\u8fc7\u4e86\u5141\u8bb8\u7684\u6700\u5927\u5927\u5c0f\u3002',
|
||||
},
|
||||
'error.rateLimited': {
|
||||
en: 'Too many requests. Please retry later.',
|
||||
zh: '\u8bf7\u6c42\u8fc7\u591a\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002',
|
||||
},
|
||||
'error.internalError': {
|
||||
en: 'An internal server error occurred.',
|
||||
zh: '\u53d1\u751f\u4e86\u5185\u90e8\u670d\u52a1\u5668\u9519\u8bef\u3002',
|
||||
},
|
||||
'error.unauthorized': {
|
||||
en: 'Authentication is required.',
|
||||
zh: '\u9700\u8981\u8eab\u4efd\u9a8c\u8bc1\u3002',
|
||||
},
|
||||
'error.forbidden': {
|
||||
en: 'You do not have permission to perform this action.',
|
||||
zh: '\u60a8\u6ca1\u6709\u6267\u884c\u6b64\u64cd\u4f5c\u7684\u6743\u9650\u3002',
|
||||
},
|
||||
'error.notFound': {
|
||||
en: 'The requested resource was not found.',
|
||||
zh: '\u672a\u627e\u5230\u8bf7\u6c42\u7684\u8d44\u6e90\u3002',
|
||||
},
|
||||
'error.validationError': {
|
||||
en: 'The request failed validation.',
|
||||
zh: '\u8bf7\u6c42\u672a\u901a\u8fc7\u9a8c\u8bc1\u3002',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth middleware (src/middleware/auth.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'auth.missingHeader': {
|
||||
en: 'Missing Authorization header',
|
||||
zh: '\u7f3a\u5c11 Authorization \u8bf7\u6c42\u5934',
|
||||
},
|
||||
'auth.bearerRequired': {
|
||||
en: 'Authorization header must use Bearer scheme',
|
||||
zh: 'Authorization \u8bf7\u6c42\u5934\u5fc5\u987b\u4f7f\u7528 Bearer \u65b9\u6848',
|
||||
},
|
||||
'auth.emptyToken': {
|
||||
en: 'Empty Bearer token',
|
||||
zh: 'Bearer \u4ee4\u724c\u4e3a\u7a7a',
|
||||
},
|
||||
'auth.malformedJwt': {
|
||||
en: 'Malformed JWT: expected 3 parts',
|
||||
zh: 'JWT \u683c\u5f0f\u9519\u8bef\uff1a\u5e94\u5305\u542b 3 \u4e2a\u90e8\u5206',
|
||||
},
|
||||
'auth.invalidSignature': {
|
||||
en: 'Invalid JWT signature',
|
||||
zh: 'JWT \u7b7e\u540d\u65e0\u6548',
|
||||
},
|
||||
'auth.malformedPayload': {
|
||||
en: 'Malformed JWT payload',
|
||||
zh: 'JWT \u8d1f\u8f7d\u683c\u5f0f\u9519\u8bef',
|
||||
},
|
||||
'auth.missingSub': {
|
||||
en: 'JWT missing required "sub" claim',
|
||||
zh: 'JWT \u7f3a\u5c11\u5fc5\u9700\u7684 "sub" \u58f0\u660e',
|
||||
},
|
||||
'auth.missingOrgId': {
|
||||
en: 'JWT missing required "org_id" claim',
|
||||
zh: 'JWT \u7f3a\u5c11\u5fc5\u9700\u7684 "org_id" \u58f0\u660e',
|
||||
},
|
||||
'auth.missingRole': {
|
||||
en: 'JWT missing required "role" claim',
|
||||
zh: 'JWT \u7f3a\u5c11\u5fc5\u9700\u7684 "role" \u58f0\u660e',
|
||||
},
|
||||
'auth.missingExp': {
|
||||
en: 'JWT missing required "exp" claim',
|
||||
zh: 'JWT \u7f3a\u5c11\u5fc5\u9700\u7684 "exp" \u58f0\u660e',
|
||||
},
|
||||
'auth.expired': {
|
||||
en: 'JWT has expired',
|
||||
zh: 'JWT \u5df2\u8fc7\u671f',
|
||||
},
|
||||
'auth.unsupportedAlgorithm': {
|
||||
en: 'Unsupported JWT algorithm. Only HS256 is accepted.',
|
||||
zh: '\u4e0d\u652f\u6301\u7684 JWT \u7b97\u6cd5\u3002\u4ec5\u63a5\u53d7 HS256\u3002',
|
||||
},
|
||||
'auth.ttlMustBePositive': {
|
||||
en: 'ttl_seconds must be a positive number',
|
||||
zh: 'ttl_seconds \u5fc5\u987b\u4e3a\u6b63\u6570',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RBAC middleware (src/middleware/rbac.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'rbac.noRole': {
|
||||
en: 'No role found in request context',
|
||||
zh: '\u8bf7\u6c42\u4e0a\u4e0b\u6587\u4e2d\u672a\u627e\u5230\u89d2\u8272',
|
||||
},
|
||||
'rbac.insufficientPermissions': {
|
||||
en: 'Insufficient permissions for this action.',
|
||||
zh: '\u6743\u9650\u4e0d\u8db3\uff0c\u65e0\u6cd5\u6267\u884c\u6b64\u64cd\u4f5c\u3002',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 404 handler (src/index.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'notFound.resource': {
|
||||
en: 'The requested resource was not found.',
|
||||
zh: '\u672a\u627e\u5230\u8bf7\u6c42\u7684\u8d44\u6e90\u3002',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth routes (src/routes/auth.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'auth.ttlMustBeNumber': {
|
||||
en: 'ttl_seconds must be a number or null',
|
||||
zh: 'ttl_seconds \u5fc5\u987b\u4e3a\u6570\u5b57\u6216 null',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent routes (src/routes/agents.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'agent.missingAgentId': {
|
||||
en: 'Missing required field "agent_id".',
|
||||
zh: '\u7f3a\u5c11\u5fc5\u586b\u5b57\u6bb5 "agent_id"\u3002',
|
||||
},
|
||||
'agent.missingIntegrationType': {
|
||||
en: 'Missing required field "integration_type".',
|
||||
zh: '\u7f3a\u5c11\u5fc5\u586b\u5b57\u6bb5 "integration_type"\u3002',
|
||||
},
|
||||
'agent.missingReason': {
|
||||
en: 'Missing required field "reason".',
|
||||
zh: '\u7f3a\u5c11\u5fc5\u586b\u5b57\u6bb5 "reason"\u3002',
|
||||
},
|
||||
'agent.missingKid': {
|
||||
en: 'Missing required field "kid".',
|
||||
zh: '\u7f3a\u5c11\u5fc5\u586b\u5b57\u6bb5 "kid"\u3002',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operation routes (src/routes/operations.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'operation.invalidBody': {
|
||||
en: 'Invalid or empty request body.',
|
||||
zh: '\u8bf7\u6c42\u4f53\u65e0\u6548\u6216\u4e3a\u7a7a\u3002',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export routes (src/routes/exports.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'export.bodyRequired': {
|
||||
en: 'Request body is required.',
|
||||
zh: '\u8bf7\u6c42\u4f53\u4e3a\u5fc5\u586b\u9879\u3002',
|
||||
},
|
||||
'export.notFoundById': {
|
||||
en: 'Export "{id}" not found.',
|
||||
zh: '\u672a\u627e\u5230\u5bfc\u51fa\u8bb0\u5f55 "{id}"\u3002',
|
||||
},
|
||||
'export.notYetComplete': {
|
||||
en: 'Export is not yet complete.',
|
||||
zh: '\u5bfc\u51fa\u5c1a\u672a\u5b8c\u6210\u3002',
|
||||
},
|
||||
'export.fileNotFound': {
|
||||
en: 'Export file not found in storage.',
|
||||
zh: '\u5728\u5b58\u50a8\u4e2d\u672a\u627e\u5230\u5bfc\u51fa\u6587\u4ef6\u3002',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth service (src/services/auth-service.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'auth.emailPasswordRequired': {
|
||||
en: 'Email and password (8-128 chars) are required',
|
||||
zh: '\u7535\u5b50\u90ae\u4ef6\u548c\u5bc6\u7801\uff088-128 \u4e2a\u5b57\u7b26\uff09\u4e3a\u5fc5\u586b\u9879',
|
||||
},
|
||||
'auth.emailAlreadyRegistered': {
|
||||
en: 'Email already registered',
|
||||
zh: '\u7535\u5b50\u90ae\u4ef6\u5df2\u88ab\u6ce8\u518c',
|
||||
},
|
||||
'auth.invalidCredentials': {
|
||||
en: 'Invalid email or password',
|
||||
zh: '\u7535\u5b50\u90ae\u4ef6\u6216\u5bc6\u7801\u65e0\u6548',
|
||||
},
|
||||
'auth.accountSuspended': {
|
||||
en: 'Account is suspended',
|
||||
zh: '\u8d26\u6237\u5df2\u88ab\u505c\u7528',
|
||||
},
|
||||
'auth.userNotFound': {
|
||||
en: 'User not found',
|
||||
zh: '\u672a\u627e\u5230\u7528\u6237',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent service (src/services/agent-service.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'agent.alreadyExists': {
|
||||
en: 'An agent with id "{id}" already exists.',
|
||||
zh: 'ID \u4e3a "{id}" \u7684\u4ee3\u7406\u5df2\u5b58\u5728\u3002',
|
||||
},
|
||||
'agent.atLeastOneKey': {
|
||||
en: 'At least one signing key must be provided.',
|
||||
zh: '\u5fc5\u987b\u63d0\u4f9b\u81f3\u5c11\u4e00\u4e2a\u7b7e\u540d\u5bc6\u94a5\u3002',
|
||||
},
|
||||
'agent.notFound': {
|
||||
en: 'Agent "{id}" not found.',
|
||||
zh: '\u672a\u627e\u5230\u4ee3\u7406 "{id}"\u3002',
|
||||
},
|
||||
'agent.invalidIntegrationType': {
|
||||
en: 'Invalid integration_type "{value}". Must be one of: {valid}.',
|
||||
zh: '\u65e0\u6548\u7684 integration_type "{value}"\u3002\u5fc5\u987b\u4e3a\u4ee5\u4e0b\u4e4b\u4e00\uff1a{valid}\u3002',
|
||||
},
|
||||
'agent.alreadyFrozen': {
|
||||
en: 'Agent "{id}" is already frozen.',
|
||||
zh: '\u4ee3\u7406 "{id}" \u5df2\u88ab\u51bb\u7ed3\u3002',
|
||||
},
|
||||
'agent.permanentlyRevoked': {
|
||||
en: 'Agent "{id}" has been permanently revoked.',
|
||||
zh: '\u4ee3\u7406 "{id}" \u5df2\u88ab\u6c38\u4e45\u64a4\u9500\u3002',
|
||||
},
|
||||
'agent.notFrozen': {
|
||||
en: 'Agent "{id}" is not frozen.',
|
||||
zh: '\u4ee3\u7406 "{id}" \u672a\u88ab\u51bb\u7ed3\u3002',
|
||||
},
|
||||
'agent.permanentlyRevokedCannotUnfreeze': {
|
||||
en: 'Agent "{id}" has been permanently revoked and cannot be unfrozen.',
|
||||
zh: '\u4ee3\u7406 "{id}" \u5df2\u88ab\u6c38\u4e45\u64a4\u9500\uff0c\u65e0\u6cd5\u89e3\u51bb\u3002',
|
||||
},
|
||||
'agent.keyNotFound': {
|
||||
en: 'Key "{kid}" not found for agent "{id}".',
|
||||
zh: '\u672a\u627e\u5230\u4ee3\u7406 "{id}" \u7684\u5bc6\u94a5 "{kid}"\u3002',
|
||||
},
|
||||
'agent.keyAlreadyRevoked': {
|
||||
en: 'Key "{kid}" is already revoked.',
|
||||
zh: '\u5bc6\u94a5 "{kid}" \u5df2\u88ab\u64a4\u9500\u3002',
|
||||
},
|
||||
'agent.unsupportedAlgorithm': {
|
||||
en: 'Unsupported key algorithm "{algorithm}". Only "ed25519" is supported.',
|
||||
zh: '\u4e0d\u652f\u6301\u7684\u5bc6\u94a5\u7b97\u6cd5 "{algorithm}"\u3002\u4ec5\u652f\u6301 "ed25519"\u3002',
|
||||
},
|
||||
'agent.invalidPublicKeyLength': {
|
||||
en: 'Public key "{kid}" must be 32 bytes (got {actual}).',
|
||||
zh: '\u516c\u94a5 "{kid}" \u5fc5\u987b\u4e3a 32 \u5b57\u8282\uff08\u5b9e\u9645 {actual}\uff09\u3002',
|
||||
},
|
||||
'agent.invalidPublicKeyEncoding': {
|
||||
en: 'Public key "{kid}" has invalid base64url encoding.',
|
||||
zh: '\u516c\u94a5 "{kid}" \u7684 base64url \u7f16\u7801\u65e0\u6548\u3002',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operation service (src/services/operation-service.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'operation.agentNotRegistered': {
|
||||
en: 'Agent "{id}" is not registered.',
|
||||
zh: '\u4ee3\u7406 "{id}" \u672a\u6ce8\u518c\u3002',
|
||||
},
|
||||
'operation.agentRevoked': {
|
||||
en: 'The agent has been permanently revoked.',
|
||||
zh: '\u8be5\u4ee3\u7406\u5df2\u88ab\u6c38\u4e45\u64a4\u9500\u3002',
|
||||
},
|
||||
'operation.keyNotFoundForAgent': {
|
||||
en: 'Key "{kid}" not found for agent "{id}".',
|
||||
zh: '\u672a\u627e\u5230\u4ee3\u7406 "{id}" \u7684\u5bc6\u94a5 "{kid}"\u3002',
|
||||
},
|
||||
'operation.keyRetired': {
|
||||
en: 'The signing key has been retired.',
|
||||
zh: '\u7b7e\u540d\u5bc6\u94a5\u5df2\u88ab\u505c\u7528\u3002',
|
||||
},
|
||||
'operation.prevHashMismatch': {
|
||||
en: 'Expected prev_chain_hash "{expected}", got "{actual}".',
|
||||
zh: '\u9884\u671f\u7684 prev_chain_hash \u4e3a "{expected}"\uff0c\u5b9e\u9645\u4e3a "{actual}"\u3002',
|
||||
},
|
||||
'operation.unsupportedVersion': {
|
||||
en: 'Unsupported op_version "{version}". Only "1.0" is supported.',
|
||||
zh: '\u4e0d\u652f\u6301\u7684 op_version "{version}"\u3002\u4ec5\u652f\u6301 "1.0"\u3002',
|
||||
},
|
||||
'operation.missingField': {
|
||||
en: 'Missing or empty required field "{field}".',
|
||||
zh: '\u7f3a\u5c11\u6216\u4e3a\u7a7a\u7684\u5fc5\u586b\u5b57\u6bb5 "{field}"\u3002',
|
||||
},
|
||||
'operation.nonceTooLong': {
|
||||
en: 'Nonce exceeds maximum length of {max} characters.',
|
||||
zh: '\u968f\u673a\u6570\u8d85\u8fc7\u4e86\u6700\u5927\u957f\u5ea6 {max} \u4e2a\u5b57\u7b26\u3002',
|
||||
},
|
||||
'operation.invalidIssuedAt': {
|
||||
en: 'Invalid "issued_at" timestamp.',
|
||||
zh: '\u65e0\u6548\u7684 "issued_at" \u65f6\u95f4\u6233\u3002',
|
||||
},
|
||||
'operation.missingTtl': {
|
||||
en: 'Missing or invalid "ttl_ms".',
|
||||
zh: '\u7f3a\u5c11\u6216\u65e0\u6548\u7684 "ttl_ms"\u3002',
|
||||
},
|
||||
'operation.ttlTooLow': {
|
||||
en: 'ttl_ms must be at least {min}ms.',
|
||||
zh: 'ttl_ms \u81f3\u5c11\u4e3a {min} \u6beb\u79d2\u3002',
|
||||
},
|
||||
'operation.ttlTooHigh': {
|
||||
en: 'ttl_ms must not exceed {max}ms.',
|
||||
zh: 'ttl_ms \u4e0d\u80fd\u8d85\u8fc7 {max} \u6beb\u79d2\u3002',
|
||||
},
|
||||
'operation.notFound': {
|
||||
en: 'Operation "{id}" not found.',
|
||||
zh: '\u672a\u627e\u5230\u64cd\u4f5c "{id}"\u3002',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export service (src/services/export-service.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'export.invalidStartTime': {
|
||||
en: 'Invalid "start_time" parameter.',
|
||||
zh: '\u65e0\u6548\u7684 "start_time" \u53c2\u6570\u3002',
|
||||
},
|
||||
'export.invalidEndTime': {
|
||||
en: 'Invalid "end_time" parameter.',
|
||||
zh: '\u65e0\u6548\u7684 "end_time" \u53c2\u6570\u3002',
|
||||
},
|
||||
'export.startBeforeEnd': {
|
||||
en: '"start_time" must be before "end_time".',
|
||||
zh: '"start_time" \u5fc5\u987b\u65e9\u4e8e "end_time"\u3002',
|
||||
},
|
||||
'export.invalidFormat': {
|
||||
en: 'Format must be "json" or "pdf".',
|
||||
zh: '\u683c\u5f0f\u5fc5\u987b\u4e3a "json" \u6216 "pdf"\u3002',
|
||||
},
|
||||
'export.notFound': {
|
||||
en: 'Export "{id}" not found.',
|
||||
zh: '\u672a\u627e\u5230\u5bfc\u51fa\u8bb0\u5f55 "{id}"\u3002',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audit service (src/services/audit-service.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'audit.invalidStartTime': {
|
||||
en: 'Invalid "start_time" parameter.',
|
||||
zh: '\u65e0\u6548\u7684 "start_time" \u53c2\u6570\u3002',
|
||||
},
|
||||
'audit.invalidEndTime': {
|
||||
en: 'Invalid "end_time" parameter.',
|
||||
zh: '\u65e0\u6548\u7684 "end_time" \u53c2\u6570\u3002',
|
||||
},
|
||||
'audit.invalidCursor': {
|
||||
en: 'Invalid cursor value.',
|
||||
zh: '\u65e0\u6548\u7684\u6e38\u6807\u503c\u3002',
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Epoch service (src/services/epoch-service.ts)
|
||||
// ---------------------------------------------------------------------------
|
||||
'epoch.notFound': {
|
||||
en: 'Epoch "{id}" not found.',
|
||||
zh: '\u672a\u627e\u5230\u65f6\u671f "{id}"\u3002',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve a translated message by key.
|
||||
*
|
||||
* Supports simple {param} placeholder interpolation via the optional
|
||||
* `params` argument.
|
||||
*
|
||||
* Falls back to English if the requested language is missing, and
|
||||
* ultimately falls back to the raw key if no translation exists at all.
|
||||
*/
|
||||
export function getMessage(
|
||||
key: string,
|
||||
lang: Lang = 'en',
|
||||
params?: Record<string, string | number>,
|
||||
): string {
|
||||
let msg = messages[key]?.[lang] ?? messages[key]?.en ?? key;
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
msg = msg.replaceAll(`{${k}}`, String(v));
|
||||
}
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the preferred language from an Accept-Language header value.
|
||||
*
|
||||
* Returns 'zh' if the header contains any Chinese locale indicator,
|
||||
* otherwise defaults to 'en'.
|
||||
*/
|
||||
export function detectLanguage(acceptLanguage: string | null | undefined): Lang {
|
||||
if (!acceptLanguage) return 'en';
|
||||
if (acceptLanguage.includes('zh')) return 'zh';
|
||||
return 'en';
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Elydora API — Node.js entry point.
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { serve } from '@hono/node-server';
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import type { Env, AppVariables } from './types.js';
|
||||
import { requestIdMiddleware } from './middleware/request-id.js';
|
||||
import { i18nMiddleware } from './middleware/i18n.js';
|
||||
import { globalErrorHandler } from './middleware/error-handler.js';
|
||||
import { auth } from './routes/auth.js';
|
||||
import { agents } from './routes/agents.js';
|
||||
import { operations } from './routes/operations.js';
|
||||
import { audit } from './routes/audit.js';
|
||||
import { epochs } from './routes/epochs.js';
|
||||
import { exports_ } from './routes/exports.js';
|
||||
import { jwks } from './routes/jwks.js';
|
||||
import { getMessage } from './i18n/messages.js';
|
||||
|
||||
// Adapter imports
|
||||
import { PostgresAdapter } from './adapters/postgres.js';
|
||||
import { MinioAdapter } from './adapters/minio.js';
|
||||
import { RedisCacheAdapter } from './adapters/redis-cache.js';
|
||||
import { BullMQAdapter } from './adapters/bullmq.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialize adapters from environment variables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const db = new PostgresAdapter(process.env.DATABASE_URL!);
|
||||
const evidence = new MinioAdapter(
|
||||
process.env.MINIO_ENDPOINT!,
|
||||
process.env.MINIO_ACCESS_KEY!,
|
||||
process.env.MINIO_SECRET_KEY!,
|
||||
process.env.MINIO_BUCKET ?? 'elydora-evidence',
|
||||
);
|
||||
const cache = new RedisCacheAdapter(process.env.REDIS_URL!);
|
||||
const redisUrl = new URL(process.env.REDIS_URL!);
|
||||
const queue = new BullMQAdapter({
|
||||
host: redisUrl.hostname,
|
||||
port: Number(redisUrl.port) || 6379,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build the env bindings object
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const env: Env = {
|
||||
ELYDORA_DB: db,
|
||||
ELYDORA_EVIDENCE: evidence,
|
||||
ELYDORA_CACHE: cache,
|
||||
ELYDORA_QUEUE: queue,
|
||||
ENVIRONMENT: process.env.ENVIRONMENT ?? 'production',
|
||||
API_VERSION: process.env.API_VERSION ?? 'v1',
|
||||
PROTOCOL_VERSION: process.env.PROTOCOL_VERSION ?? '1.0',
|
||||
JWT_SECRET: process.env.JWT_SECRET!,
|
||||
ELYDORA_SIGNING_KEY: process.env.ELYDORA_SIGNING_KEY!,
|
||||
ALLOWED_ORIGINS: process.env.ALLOWED_ORIGINS ?? '',
|
||||
TSA_URL: process.env.TSA_URL,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create the Hono application
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const app = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||
|
||||
// Inject env bindings into every request context
|
||||
app.use('/*', async (c, next) => {
|
||||
// Copy all env bindings onto c.env
|
||||
Object.assign(c.env, env);
|
||||
await next();
|
||||
});
|
||||
|
||||
// CORS
|
||||
app.use(
|
||||
'/*',
|
||||
cors({
|
||||
origin: (origin, c) => {
|
||||
if (c.env.ENVIRONMENT === 'production') {
|
||||
const raw = c.env.ALLOWED_ORIGINS ?? '';
|
||||
const allowedOrigins = raw.split(',').map((s: string) => s.trim()).filter(Boolean);
|
||||
return allowedOrigins.includes(origin) ? origin : '';
|
||||
}
|
||||
return origin;
|
||||
},
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowHeaders: [
|
||||
'Content-Type',
|
||||
'Authorization',
|
||||
'X-Elydora-Protocol-Version',
|
||||
'X-Elydora-Signature',
|
||||
'X-Request-Id',
|
||||
],
|
||||
exposeHeaders: [
|
||||
'X-Request-Id',
|
||||
'X-Elydora-Protocol-Version',
|
||||
'X-RateLimit-Limit',
|
||||
'X-RateLimit-Remaining',
|
||||
'X-RateLimit-Reset',
|
||||
],
|
||||
maxAge: 86400,
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Security headers
|
||||
app.use('/*', async (c, next) => {
|
||||
await next();
|
||||
c.header('X-Content-Type-Options', 'nosniff');
|
||||
c.header('X-Frame-Options', 'DENY');
|
||||
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
c.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||
c.header('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'");
|
||||
if (c.env.ENVIRONMENT === 'production') {
|
||||
c.header('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');
|
||||
}
|
||||
});
|
||||
|
||||
// Request ID
|
||||
app.use('/*', requestIdMiddleware);
|
||||
|
||||
// Language detection
|
||||
app.use('/*', i18nMiddleware);
|
||||
|
||||
// Global error handler
|
||||
app.onError(globalErrorHandler);
|
||||
|
||||
// Health check
|
||||
app.get('/v1/health', (c) => {
|
||||
return c.json(
|
||||
{
|
||||
status: 'healthy',
|
||||
version: c.env.API_VERSION,
|
||||
protocol_version: c.env.PROTOCOL_VERSION,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
// Route groups
|
||||
app.route('/.well-known/elydora/jwks.json', jwks);
|
||||
app.route('/v1/auth', auth);
|
||||
app.route('/v1/agents', agents);
|
||||
app.route('/v1/operations', operations);
|
||||
app.route('/v1/audit', audit);
|
||||
app.route('/v1/epochs', epochs);
|
||||
app.route('/v1/exports', exports_);
|
||||
|
||||
// 404 fallback
|
||||
app.notFound((c) => {
|
||||
const requestId = c.get('request_id') ?? 'unknown';
|
||||
const lang = c.get('lang') ?? 'en';
|
||||
return c.json(
|
||||
{
|
||||
error: {
|
||||
code: 'NOT_FOUND',
|
||||
message: getMessage('notFound.resource', lang),
|
||||
request_id: requestId,
|
||||
},
|
||||
},
|
||||
404,
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Start the server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const port = Number(process.env.PORT) || 8787;
|
||||
|
||||
await cache.connect();
|
||||
console.log(`Elydora API server starting on port ${port}`);
|
||||
|
||||
serve({ fetch: app.fetch, port }, (info) => {
|
||||
console.log(`Elydora API server listening on http://localhost:${info.port}`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('SIGTERM received, shutting down...');
|
||||
await cache.close();
|
||||
await db.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('SIGINT received, shutting down...');
|
||||
await cache.close();
|
||||
await db.close();
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* JWT authentication middleware.
|
||||
*
|
||||
* Verifies Bearer tokens in the Authorization header using HMAC-SHA256.
|
||||
* On success, extracts `org_id`, `role`, and `sub` (actor) claims and
|
||||
* stores them in the Hono context variables for downstream handlers.
|
||||
*
|
||||
* Token structure (payload):
|
||||
* {
|
||||
* "sub": "user_abc123", // actor identifier
|
||||
* "org_id": "org_acme", // organization scope
|
||||
* "role": "org_owner", // RBAC role
|
||||
* "iat": 1700000000, // issued-at (seconds)
|
||||
* "exp": 1700003600 // expiration (seconds)
|
||||
* }
|
||||
*/
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import type { RbacRole } from '../shared/index.js';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import { AppError } from './error-handler.js';
|
||||
import { base64urlDecode } from '../utils/crypto.js';
|
||||
|
||||
/** Minimal JWT payload shape we require. */
|
||||
interface JWTPayload {
|
||||
sub: string;
|
||||
org_id: string;
|
||||
role: RbacRole;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a JWT using HMAC-SHA256 and return its payload.
|
||||
*/
|
||||
async function verifyJWT(token: string, secret: string): Promise<JWTPayload> {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.malformedJwt' });
|
||||
}
|
||||
|
||||
const headerB64 = parts[0]!;
|
||||
const payloadB64 = parts[1]!;
|
||||
const signatureB64 = parts[2]!;
|
||||
|
||||
// Validate JWT header — only HS256 is accepted
|
||||
try {
|
||||
const headerBytes = base64urlDecode(headerB64);
|
||||
const header = JSON.parse(new TextDecoder().decode(headerBytes)) as { alg?: string; typ?: string };
|
||||
if (header.alg !== 'HS256') {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.unsupportedAlgorithm' });
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof AppError) throw e;
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.malformedJwt' });
|
||||
}
|
||||
|
||||
// Import the HMAC key
|
||||
const encoder = new TextEncoder();
|
||||
const keyData = encoder.encode(secret);
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyData,
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['verify'],
|
||||
);
|
||||
|
||||
// Verify signature over "header.payload"
|
||||
const signedInput = encoder.encode(`${headerB64}.${payloadB64}`);
|
||||
const signatureBytes = base64urlDecode(signatureB64);
|
||||
|
||||
const valid = await crypto.subtle.verify('HMAC', cryptoKey, signatureBytes as unknown as Uint8Array<ArrayBuffer>, signedInput as unknown as Uint8Array<ArrayBuffer>);
|
||||
|
||||
if (!valid) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.invalidSignature' });
|
||||
}
|
||||
|
||||
// Decode and validate payload
|
||||
const payloadBytes = base64urlDecode(payloadB64);
|
||||
const payloadJson = new TextDecoder().decode(payloadBytes);
|
||||
let payload: JWTPayload;
|
||||
|
||||
try {
|
||||
payload = JSON.parse(payloadJson);
|
||||
} catch {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.malformedPayload' });
|
||||
}
|
||||
|
||||
// Validate required claims
|
||||
if (!payload.sub || typeof payload.sub !== 'string') {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingSub' });
|
||||
}
|
||||
if (!payload.org_id || typeof payload.org_id !== 'string') {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingOrgId' });
|
||||
}
|
||||
if (!payload.role || typeof payload.role !== 'string') {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingRole' });
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
if (typeof payload.exp !== 'number') {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingExp' });
|
||||
}
|
||||
if (payload.exp < nowSeconds) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.expired' });
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentication middleware.
|
||||
*
|
||||
* Extracts the Bearer token, verifies it, and sets org_id, role, and
|
||||
* actor on the context. Should be applied to all routes that require
|
||||
* authentication.
|
||||
*/
|
||||
export const authMiddleware: MiddlewareHandler<{
|
||||
Bindings: Env;
|
||||
Variables: AppVariables;
|
||||
}> = async (c, next) => {
|
||||
const authHeader = c.req.header('Authorization');
|
||||
|
||||
if (!authHeader) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingHeader' });
|
||||
}
|
||||
|
||||
if (!authHeader.startsWith('Bearer ')) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.bearerRequired' });
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7).trim();
|
||||
|
||||
if (!token) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.emptyToken' });
|
||||
}
|
||||
|
||||
const payload = await verifyJWT(token, c.env.JWT_SECRET);
|
||||
|
||||
c.set('org_id', payload.org_id);
|
||||
c.set('role', payload.role);
|
||||
c.set('actor', payload.sub);
|
||||
|
||||
await next();
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Global error handler middleware.
|
||||
*
|
||||
* Catches all uncaught exceptions and formats them as a standardized
|
||||
* ErrorResponse with the appropriate HTTP status code, error code,
|
||||
* and the request's unique identifier for tracing.
|
||||
*
|
||||
* When an AppError carries a `messageKey`, the handler resolves it to
|
||||
* the correct language using the `lang` context variable set by the
|
||||
* i18n middleware. This allows services to throw errors with i18n keys
|
||||
* without needing direct access to the Hono context.
|
||||
*/
|
||||
|
||||
import type { ErrorHandler } from 'hono';
|
||||
import type { ErrorCode, ErrorResponse } from '../shared/index.js';
|
||||
import { ERROR_CODES } from '../shared/index.js';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import { getMessage } from '../i18n/messages.js';
|
||||
import type { Lang } from '../i18n/messages.js';
|
||||
|
||||
/**
|
||||
* Maps each ErrorCode to its corresponding i18n message key.
|
||||
*
|
||||
* This is used when an AppError is thrown with only an error code
|
||||
* (no explicit message or i18n key), so the global error handler
|
||||
* can still resolve a translated default message.
|
||||
*/
|
||||
const ERROR_CODE_TO_I18N_KEY: Record<ErrorCode, string> = {
|
||||
INVALID_SIGNATURE: 'error.invalidSignature',
|
||||
UNKNOWN_AGENT: 'error.unknownAgent',
|
||||
KEY_REVOKED: 'error.keyRevoked',
|
||||
AGENT_FROZEN: 'error.agentFrozen',
|
||||
TTL_EXPIRED: 'error.ttlExpired',
|
||||
REPLAY_DETECTED: 'error.replayDetected',
|
||||
PREV_HASH_MISMATCH: 'error.prevHashMismatch',
|
||||
PAYLOAD_TOO_LARGE: 'error.payloadTooLarge',
|
||||
RATE_LIMITED: 'error.rateLimited',
|
||||
INTERNAL_ERROR: 'error.internalError',
|
||||
UNAUTHORIZED: 'error.unauthorized',
|
||||
FORBIDDEN: 'error.forbidden',
|
||||
NOT_FOUND: 'error.notFound',
|
||||
VALIDATION_ERROR: 'error.validationError',
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom error class that carries an HTTP status and Elydora error code.
|
||||
*
|
||||
* Supports three modes:
|
||||
* 1. Code-only: `new AppError(status, code)` — uses ErrorCode default message
|
||||
* 2. i18n key: `new AppError(status, code, { key: 'msg.key', params: {...} })` —
|
||||
* the message is resolved at response time from the translation map
|
||||
* 3. Raw string: `new AppError(status, code, 'raw message')` — message is used as-is
|
||||
* (should be avoided in new code; prefer mode 2)
|
||||
*/
|
||||
export class AppError extends Error {
|
||||
public readonly statusCode: number;
|
||||
public readonly errorCode: ErrorCode;
|
||||
public readonly details?: Record<string, unknown>;
|
||||
public readonly messageKey?: string;
|
||||
public readonly messageParams?: Record<string, string | number>;
|
||||
/** True when the error was created with only an error code (no custom message). */
|
||||
public readonly usesDefaultMessage: boolean;
|
||||
|
||||
constructor(
|
||||
statusCode: number,
|
||||
errorCode: ErrorCode,
|
||||
messageOrOpts?: string | { key: string; params?: Record<string, string | number> },
|
||||
details?: Record<string, unknown>,
|
||||
) {
|
||||
if (typeof messageOrOpts === 'object' && messageOrOpts !== null) {
|
||||
// i18n mode — store the key; resolve later in the error handler
|
||||
super(getMessage(messageOrOpts.key, 'en', messageOrOpts.params));
|
||||
this.messageKey = messageOrOpts.key;
|
||||
this.messageParams = messageOrOpts.params;
|
||||
this.usesDefaultMessage = false;
|
||||
} else if (messageOrOpts === undefined) {
|
||||
// Code-only mode — will be resolved via ERROR_CODE_TO_I18N_KEY
|
||||
super(ERROR_CODES[errorCode]);
|
||||
this.usesDefaultMessage = true;
|
||||
} else {
|
||||
// Raw string mode (legacy)
|
||||
super(messageOrOpts);
|
||||
this.usesDefaultMessage = false;
|
||||
}
|
||||
this.name = 'AppError';
|
||||
this.statusCode = statusCode;
|
||||
this.errorCode = errorCode;
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the user-facing message for a given language.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. If a messageKey was provided, use that with the translation map.
|
||||
* 2. If the error used the default ErrorCode message, translate via
|
||||
* the ERROR_CODE_TO_I18N_KEY mapping.
|
||||
* 3. Otherwise return the raw message string.
|
||||
*/
|
||||
resolveMessage(lang: Lang): string {
|
||||
if (this.messageKey) {
|
||||
return getMessage(this.messageKey, lang, this.messageParams);
|
||||
}
|
||||
if (this.usesDefaultMessage) {
|
||||
const i18nKey = ERROR_CODE_TO_I18N_KEY[this.errorCode];
|
||||
if (i18nKey) {
|
||||
return getMessage(i18nKey, lang);
|
||||
}
|
||||
}
|
||||
return this.message;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a standardised error response body.
|
||||
*
|
||||
* When `lang` is provided and no explicit `message` is given, the
|
||||
* ErrorCode default message is resolved from the translation map.
|
||||
*/
|
||||
export function buildErrorResponse(
|
||||
code: ErrorCode,
|
||||
requestId: string,
|
||||
message?: string,
|
||||
details?: Record<string, unknown>,
|
||||
lang?: Lang,
|
||||
): ErrorResponse {
|
||||
let resolvedMessage = message;
|
||||
if (!resolvedMessage) {
|
||||
const i18nKey = ERROR_CODE_TO_I18N_KEY[code];
|
||||
resolvedMessage = i18nKey ? getMessage(i18nKey, lang ?? 'en') : ERROR_CODES[code];
|
||||
}
|
||||
return {
|
||||
error: {
|
||||
code,
|
||||
message: resolvedMessage,
|
||||
request_id: requestId,
|
||||
...(details ? { details } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hono error handler that converts all errors into the ErrorResponse format.
|
||||
*
|
||||
* Resolves i18n message keys using the `lang` context variable set by
|
||||
* the i18n middleware.
|
||||
*/
|
||||
export const globalErrorHandler: ErrorHandler<{
|
||||
Bindings: Env;
|
||||
Variables: AppVariables;
|
||||
}> = (err, c) => {
|
||||
const requestId = c.get('request_id') ?? 'unknown';
|
||||
const lang: Lang = c.get('lang') ?? 'en';
|
||||
|
||||
if (err instanceof AppError) {
|
||||
const resolvedMessage = err.resolveMessage(lang);
|
||||
const body = buildErrorResponse(err.errorCode, requestId, resolvedMessage, err.details);
|
||||
return c.json(body, err.statusCode as 400);
|
||||
}
|
||||
|
||||
// Log unexpected errors for observability
|
||||
console.error(`[${requestId}] Unhandled error:`, err);
|
||||
|
||||
const body = buildErrorResponse('INTERNAL_ERROR', requestId, undefined, undefined, lang);
|
||||
return c.json(body, 500);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* i18n middleware — language detection from Accept-Language header.
|
||||
*
|
||||
* Reads the Accept-Language request header, detects the preferred
|
||||
* language (English or Chinese), and stores it in the Hono context
|
||||
* so that all downstream handlers and services can access it via
|
||||
* `c.get('lang')`.
|
||||
*/
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import { detectLanguage } from '../i18n/messages.js';
|
||||
|
||||
export const i18nMiddleware: MiddlewareHandler<{
|
||||
Bindings: Env;
|
||||
Variables: AppVariables;
|
||||
}> = async (c, next) => {
|
||||
const acceptLanguage = c.req.header('Accept-Language');
|
||||
const lang = detectLanguage(acceptLanguage);
|
||||
c.set('lang', lang);
|
||||
await next();
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Role-based access control (RBAC) middleware.
|
||||
*
|
||||
* The Elydora console defines five roles with a clear privilege hierarchy:
|
||||
*
|
||||
* org_owner > security_admin > compliance_auditor > readonly_investigator
|
||||
* integration_engineer (parallel to compliance_auditor, agent-management focused)
|
||||
*
|
||||
* This middleware checks whether the role extracted by the auth middleware
|
||||
* satisfies the minimum required role for a given route.
|
||||
*/
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import type { RbacRole } from '../shared/index.js';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import { AppError } from './error-handler.js';
|
||||
|
||||
/**
|
||||
* Privilege level map. Higher number = more privilege.
|
||||
* integration_engineer is a special role with limited admin capabilities
|
||||
* for agent management only.
|
||||
*/
|
||||
const ROLE_LEVEL: Record<RbacRole, number> = {
|
||||
readonly_investigator: 10,
|
||||
integration_engineer: 20,
|
||||
compliance_auditor: 30,
|
||||
security_admin: 40,
|
||||
org_owner: 50,
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a role has at least the given privilege level.
|
||||
*/
|
||||
function hasMinimumRole(userRole: RbacRole, requiredRole: RbacRole): boolean {
|
||||
return ROLE_LEVEL[userRole] >= ROLE_LEVEL[requiredRole];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an RBAC middleware that requires a minimum role.
|
||||
*
|
||||
* Usage:
|
||||
* app.post('/v1/agents/:id/freeze', requireRole('security_admin'), handler);
|
||||
*
|
||||
* @param minimumRole - The minimum RBAC role required to access the endpoint
|
||||
*/
|
||||
export function requireRole(minimumRole: RbacRole): MiddlewareHandler<{
|
||||
Bindings: Env;
|
||||
Variables: AppVariables;
|
||||
}> {
|
||||
return async (c, next) => {
|
||||
const userRole = c.get('role');
|
||||
|
||||
if (!userRole) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'rbac.noRole' });
|
||||
}
|
||||
|
||||
if (!hasMinimumRole(userRole, minimumRole)) {
|
||||
throw new AppError(
|
||||
403,
|
||||
'FORBIDDEN',
|
||||
{ key: 'rbac.insufficientPermissions' },
|
||||
);
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Request ID middleware.
|
||||
*
|
||||
* Generates a UUIDv7 for every incoming request and stores it in the
|
||||
* Hono context variables. The ID is also set as the X-Request-Id
|
||||
* response header so clients can reference it when reporting issues.
|
||||
*/
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import { generateUUIDv7 } from '../utils/uuid.js';
|
||||
|
||||
export const requestIdMiddleware: MiddlewareHandler<{
|
||||
Bindings: Env;
|
||||
Variables: AppVariables;
|
||||
}> = async (c, next) => {
|
||||
const requestId = generateUUIDv7();
|
||||
c.set('request_id', requestId);
|
||||
c.header('X-Request-Id', requestId);
|
||||
|
||||
await next();
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Agent routes — registration, lookup, freezing, and key revocation.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import type { RegisterAgentRequest, UpdateAgentRequest, FreezeAgentRequest, UnfreezeAgentRequest, RevokeAgentRequest } from '../shared/index.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
import * as agentService from '../services/agent-service.js';
|
||||
|
||||
const agents = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||
|
||||
// All agent routes require authentication
|
||||
agents.use('/*', authMiddleware);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/agents/register
|
||||
// ---------------------------------------------------------------------------
|
||||
agents.post(
|
||||
'/register',
|
||||
requireRole('integration_engineer'),
|
||||
async (c) => {
|
||||
const body = await c.req.json<RegisterAgentRequest>();
|
||||
|
||||
if (!body || !body.agent_id) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.missingAgentId' });
|
||||
}
|
||||
|
||||
const orgId = c.get('org_id');
|
||||
const actor = c.get('actor');
|
||||
|
||||
const result = await agentService.registerAgent(c.env.ELYDORA_DB, body, orgId, actor);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 201);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /v1/agents
|
||||
// ---------------------------------------------------------------------------
|
||||
agents.get(
|
||||
'/',
|
||||
requireRole('readonly_investigator'),
|
||||
async (c) => {
|
||||
const orgId = c.get('org_id');
|
||||
const result = await agentService.listAgents(c.env.ELYDORA_DB, orgId);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /v1/agents/:agent_id
|
||||
// ---------------------------------------------------------------------------
|
||||
agents.get(
|
||||
'/:agent_id',
|
||||
requireRole('readonly_investigator'),
|
||||
async (c) => {
|
||||
const agentId = c.req.param('agent_id');
|
||||
const orgId = c.get('org_id');
|
||||
|
||||
const result = await agentService.getAgent(c.env.ELYDORA_DB, agentId, orgId);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /v1/agents/:agent_id
|
||||
// ---------------------------------------------------------------------------
|
||||
agents.delete(
|
||||
'/:agent_id',
|
||||
requireRole('security_admin'),
|
||||
async (c) => {
|
||||
const agentId = c.req.param('agent_id');
|
||||
const orgId = c.get('org_id');
|
||||
const actor = c.get('actor');
|
||||
|
||||
await agentService.deleteAgent(c.env.ELYDORA_DB, agentId, orgId, actor);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json({ deleted: true }, 200);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PATCH /v1/agents/:agent_id
|
||||
// ---------------------------------------------------------------------------
|
||||
agents.patch(
|
||||
'/:agent_id',
|
||||
requireRole('integration_engineer'),
|
||||
async (c) => {
|
||||
const agentId = c.req.param('agent_id');
|
||||
const body = await c.req.json<UpdateAgentRequest>();
|
||||
|
||||
if (!body || !body.integration_type || typeof body.integration_type !== 'string') {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.missingIntegrationType' });
|
||||
}
|
||||
|
||||
const orgId = c.get('org_id');
|
||||
const actor = c.get('actor');
|
||||
|
||||
const result = await agentService.updateAgentIntegrationType(
|
||||
c.env.ELYDORA_DB,
|
||||
agentId,
|
||||
body.integration_type,
|
||||
orgId,
|
||||
actor,
|
||||
);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/agents/:agent_id/freeze
|
||||
// ---------------------------------------------------------------------------
|
||||
agents.post(
|
||||
'/:agent_id/freeze',
|
||||
requireRole('security_admin'),
|
||||
async (c) => {
|
||||
const agentId = c.req.param('agent_id');
|
||||
const body = await c.req.json<FreezeAgentRequest>();
|
||||
|
||||
if (!body || !body.reason || typeof body.reason !== 'string' || body.reason.trim().length === 0) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.missingReason' });
|
||||
}
|
||||
|
||||
const orgId = c.get('org_id');
|
||||
const actor = c.get('actor');
|
||||
|
||||
const result = await agentService.freezeAgent(
|
||||
c.env.ELYDORA_DB,
|
||||
agentId,
|
||||
body.reason,
|
||||
orgId,
|
||||
actor,
|
||||
);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/agents/:agent_id/unfreeze
|
||||
// ---------------------------------------------------------------------------
|
||||
agents.post(
|
||||
'/:agent_id/unfreeze',
|
||||
requireRole('security_admin'),
|
||||
async (c) => {
|
||||
const agentId = c.req.param('agent_id');
|
||||
const body = await c.req.json<UnfreezeAgentRequest>();
|
||||
|
||||
if (!body || !body.reason || typeof body.reason !== 'string' || body.reason.trim().length === 0) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.missingReason' });
|
||||
}
|
||||
|
||||
const orgId = c.get('org_id');
|
||||
const actor = c.get('actor');
|
||||
|
||||
const result = await agentService.unfreezeAgent(
|
||||
c.env.ELYDORA_DB,
|
||||
agentId,
|
||||
body.reason,
|
||||
orgId,
|
||||
actor,
|
||||
);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/agents/:agent_id/revoke
|
||||
// ---------------------------------------------------------------------------
|
||||
agents.post(
|
||||
'/:agent_id/revoke',
|
||||
requireRole('security_admin'),
|
||||
async (c) => {
|
||||
const agentId = c.req.param('agent_id');
|
||||
const body = await c.req.json<RevokeAgentRequest>();
|
||||
|
||||
if (!body || !body.kid || typeof body.kid !== 'string') {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.missingKid' });
|
||||
}
|
||||
|
||||
if (!body.reason || typeof body.reason !== 'string' || body.reason.trim().length === 0) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.missingReason' });
|
||||
}
|
||||
|
||||
const orgId = c.get('org_id');
|
||||
const actor = c.get('actor');
|
||||
|
||||
const result = await agentService.revokeKey(
|
||||
c.env.ELYDORA_DB,
|
||||
agentId,
|
||||
body.kid,
|
||||
body.reason,
|
||||
orgId,
|
||||
actor,
|
||||
);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
export { agents };
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Audit routes — query the tamper-evident audit log.
|
||||
*
|
||||
* Per the OpenAPI spec, audit queries use POST /v1/audit/query with
|
||||
* filter parameters in the request body. This avoids URL length limits
|
||||
* and supports complex filter expressions.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import type { AuditQueryRequest } from '../shared/index.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import * as auditService from '../services/audit-service.js';
|
||||
|
||||
const audit = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||
|
||||
// All audit routes require authentication and at least compliance_auditor role
|
||||
audit.use('/*', authMiddleware);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/audit/query — Query the audit log
|
||||
// ---------------------------------------------------------------------------
|
||||
audit.post(
|
||||
'/query',
|
||||
requireRole('compliance_auditor'),
|
||||
async (c) => {
|
||||
const body = await c.req.json<AuditQueryRequest>();
|
||||
const orgId = c.get('org_id');
|
||||
|
||||
const result = await auditService.queryAudit(c.env.ELYDORA_DB, body ?? {}, orgId);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
export { audit };
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Auth routes — registration, login, profile retrieval, and token refresh.
|
||||
*
|
||||
* /register and /login are public (no auth required).
|
||||
* /me and /refresh require a valid JWT.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { registerUser, loginUser, getMe, issueJWT, issueApiToken } from '../services/auth-service.js';
|
||||
import type { RbacRole } from '../shared/index.js';
|
||||
import { DEFAULT_JWT_TTL_SECONDS } from '../shared/index.js';
|
||||
import { getMessage } from '../i18n/messages.js';
|
||||
|
||||
const auth = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/auth/register — Create a new user and organization
|
||||
// ---------------------------------------------------------------------------
|
||||
auth.post('/register', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { email, password, display_name, org_name } = body;
|
||||
|
||||
const result = await registerUser(
|
||||
c.env.ELYDORA_DB,
|
||||
c.env.JWT_SECRET,
|
||||
email,
|
||||
password,
|
||||
display_name,
|
||||
org_name,
|
||||
);
|
||||
|
||||
return c.json(result, 201);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/auth/login — Authenticate and receive a JWT
|
||||
// ---------------------------------------------------------------------------
|
||||
auth.post('/login', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { email, password } = body;
|
||||
|
||||
const result = await loginUser(c.env.ELYDORA_DB, c.env.JWT_SECRET, email, password);
|
||||
|
||||
return c.json(result, 200);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /v1/auth/me — Get current user profile (auth required)
|
||||
// ---------------------------------------------------------------------------
|
||||
auth.get('/me', authMiddleware, async (c) => {
|
||||
const userId = c.get('actor');
|
||||
const user = await getMe(c.env.ELYDORA_DB, userId);
|
||||
|
||||
return c.json({ user }, 200);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/auth/refresh — Issue a new JWT (auth required)
|
||||
// ---------------------------------------------------------------------------
|
||||
auth.post('/refresh', authMiddleware, async (c) => {
|
||||
const now = Date.now();
|
||||
const token = await issueJWT(
|
||||
{
|
||||
sub: c.get('actor'),
|
||||
org_id: c.get('org_id'),
|
||||
role: c.get('role') as RbacRole,
|
||||
iat: Math.floor(now / 1000),
|
||||
exp: Math.floor(now / 1000) + DEFAULT_JWT_TTL_SECONDS,
|
||||
},
|
||||
c.env.JWT_SECRET,
|
||||
);
|
||||
|
||||
return c.json({ token }, 200);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/auth/token — Issue an API token with custom TTL (auth required)
|
||||
// ---------------------------------------------------------------------------
|
||||
auth.post('/token', authMiddleware, async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { ttl_seconds } = body;
|
||||
|
||||
if (ttl_seconds !== null && ttl_seconds !== undefined) {
|
||||
if (typeof ttl_seconds !== 'number' || ttl_seconds <= 0) {
|
||||
const lang = c.get('lang') ?? 'en';
|
||||
return c.json({ error: { code: 'VALIDATION_ERROR', message: getMessage('auth.ttlMustBeNumber', lang), request_id: '' } }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await issueApiToken(
|
||||
c.get('actor'),
|
||||
c.get('org_id'),
|
||||
c.get('role') as RbacRole,
|
||||
c.env.JWT_SECRET,
|
||||
ttl_seconds ?? null,
|
||||
);
|
||||
|
||||
return c.json(result, 200);
|
||||
});
|
||||
|
||||
export { auth };
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Epoch routes — retrieve epoch root records with optional TSA anchors.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import * as epochService from '../services/epoch-service.js';
|
||||
|
||||
const epochs = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||
|
||||
// All epoch routes require authentication
|
||||
epochs.use('/*', authMiddleware);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /v1/epochs — List all epochs for the organization
|
||||
// ---------------------------------------------------------------------------
|
||||
epochs.get('/', requireRole('readonly_investigator'), async (c) => {
|
||||
const orgId = c.get('org_id');
|
||||
const result = await epochService.listEpochs(c.env.ELYDORA_DB, orgId);
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 200);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /v1/epochs/:epoch_id — Retrieve an epoch root
|
||||
// ---------------------------------------------------------------------------
|
||||
epochs.get(
|
||||
'/:epoch_id',
|
||||
requireRole('readonly_investigator'),
|
||||
async (c) => {
|
||||
const epochId = c.req.param('epoch_id');
|
||||
const orgId = c.get('org_id');
|
||||
|
||||
const result = await epochService.getEpoch(
|
||||
c.env.ELYDORA_DB,
|
||||
c.env.ELYDORA_EVIDENCE,
|
||||
epochId,
|
||||
orgId,
|
||||
);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
export { epochs };
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Export routes — create and retrieve compliance export jobs.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import type { CreateExportRequest } from '../shared/index.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
import * as exportService from '../services/export-service.js';
|
||||
|
||||
const exports_ = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||
|
||||
// All export routes require authentication
|
||||
exports_.use('/*', authMiddleware);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /v1/exports — List all exports for the organization
|
||||
// ---------------------------------------------------------------------------
|
||||
exports_.get('/', requireRole('compliance_auditor'), async (c) => {
|
||||
const orgId = c.get('org_id');
|
||||
const result = await exportService.listExports(c.env.ELYDORA_DB, orgId);
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 200);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/exports — Create a compliance export job
|
||||
// ---------------------------------------------------------------------------
|
||||
exports_.post(
|
||||
'/',
|
||||
requireRole('compliance_auditor'),
|
||||
async (c) => {
|
||||
const body = await c.req.json<CreateExportRequest>();
|
||||
|
||||
if (!body) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'export.bodyRequired' });
|
||||
}
|
||||
|
||||
const orgId = c.get('org_id');
|
||||
const actor = c.get('actor');
|
||||
|
||||
const result = await exportService.createExport(
|
||||
c.env.ELYDORA_DB,
|
||||
c.env.ELYDORA_QUEUE,
|
||||
body,
|
||||
orgId,
|
||||
actor,
|
||||
);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 201);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /v1/exports/:export_id — Retrieve export status + download URL
|
||||
// ---------------------------------------------------------------------------
|
||||
exports_.get(
|
||||
'/:export_id',
|
||||
requireRole('compliance_auditor'),
|
||||
async (c) => {
|
||||
const exportId = c.req.param('export_id');
|
||||
const orgId = c.get('org_id');
|
||||
|
||||
const result = await exportService.getExport(
|
||||
c.env.ELYDORA_DB,
|
||||
c.env.ELYDORA_EVIDENCE,
|
||||
exportId,
|
||||
orgId,
|
||||
);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /v1/exports/:export_id/download — Stream the export file from R2
|
||||
// ---------------------------------------------------------------------------
|
||||
exports_.get(
|
||||
'/:export_id/download',
|
||||
requireRole('compliance_auditor'),
|
||||
async (c) => {
|
||||
const exportId = c.req.param('export_id');
|
||||
const orgId = c.get('org_id');
|
||||
|
||||
// Fetch the export record
|
||||
const exportRecord = await c.env.ELYDORA_DB
|
||||
.prepare('SELECT * FROM exports WHERE export_id = ? AND org_id = ?')
|
||||
.bind(exportId, orgId)
|
||||
.first<{ status: string; r2_export_key: string | null; query_params: string }>();
|
||||
|
||||
if (!exportRecord) {
|
||||
throw new AppError(404, 'NOT_FOUND', { key: 'export.notFoundById', params: { id: exportId } });
|
||||
}
|
||||
|
||||
if (exportRecord.status !== 'done' || !exportRecord.r2_export_key) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'export.notYetComplete' });
|
||||
}
|
||||
|
||||
const object = await c.env.ELYDORA_EVIDENCE.get(exportRecord.r2_export_key);
|
||||
if (!object) {
|
||||
throw new AppError(404, 'NOT_FOUND', { key: 'export.fileNotFound' });
|
||||
}
|
||||
|
||||
// Determine file extension from the stored query_params
|
||||
let ext = '';
|
||||
const qp = exportRecord as { query_params?: string };
|
||||
if (qp.query_params) {
|
||||
try {
|
||||
const params = JSON.parse(qp.query_params);
|
||||
ext = params.format === 'pdf' ? '.pdf' : '.json';
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', object.httpMetadata?.contentType ?? 'application/octet-stream');
|
||||
headers.set('Content-Disposition', `attachment; filename="export-${exportId}${ext}"`);
|
||||
|
||||
if (object.size !== undefined) {
|
||||
headers.set('Content-Length', String(object.size));
|
||||
}
|
||||
|
||||
return new Response(object.body, { headers });
|
||||
},
|
||||
);
|
||||
|
||||
export { exports_ };
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* JWKS route — public key discovery endpoint.
|
||||
*
|
||||
* Returns the JSON Web Key Set containing the Elydora server's public
|
||||
* keys used for signing attestation receipts (EAR) and epoch roots (EER).
|
||||
* External verifiers can use these keys to independently validate
|
||||
* signatures without contacting the Elydora API.
|
||||
*
|
||||
* This endpoint requires NO authentication (per OpenAPI spec and RFC 7517).
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import type { JWKSResponse } from '../shared/index.js';
|
||||
import { deriveEd25519PublicKey } from '../utils/crypto.js';
|
||||
|
||||
const jwks = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /.well-known/elydora/jwks.json — Retrieve the platform JWKS
|
||||
// ---------------------------------------------------------------------------
|
||||
jwks.get('/', async (c) => {
|
||||
// Derive the public key from the server signing key
|
||||
const publicKeyBase64url = await deriveEd25519PublicKey(c.env.ELYDORA_SIGNING_KEY);
|
||||
|
||||
const response: JWKSResponse = {
|
||||
keys: [
|
||||
{
|
||||
kty: 'OKP',
|
||||
crv: 'Ed25519',
|
||||
x: publicKeyBase64url,
|
||||
kid: 'elydora-server-key-v1',
|
||||
use: 'sig',
|
||||
alg: 'EdDSA',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
c.header('Cache-Control', 'public, max-age=3600');
|
||||
return c.json(response, 200);
|
||||
});
|
||||
|
||||
export { jwks };
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Operation routes — submit, retrieve, and verify signed operation records.
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import type { EOR } from '../shared/index.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { requireRole } from '../middleware/rbac.js';
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
import * as operationService from '../services/operation-service.js';
|
||||
|
||||
const operations = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/operations — Submit a signed EOR
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operation submission uses the EOR's embedded signature for authentication
|
||||
// rather than a Bearer JWT. The agent authenticates via its Ed25519 signature.
|
||||
// We still apply auth middleware to get the org context.
|
||||
operations.post(
|
||||
'/',
|
||||
authMiddleware,
|
||||
requireRole('integration_engineer'),
|
||||
async (c) => {
|
||||
const eorBody = await c.req.json<EOR>();
|
||||
|
||||
const orgId = c.get('org_id');
|
||||
const eor = { ...eorBody, org_id: orgId };
|
||||
|
||||
if (!eor || !eor.operation_id) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.invalidBody' });
|
||||
}
|
||||
|
||||
const result = await operationService.submitOperation(
|
||||
c.env.ELYDORA_DB,
|
||||
c.env.ELYDORA_EVIDENCE,
|
||||
c.env.ELYDORA_CACHE,
|
||||
c.env.ELYDORA_QUEUE,
|
||||
eor,
|
||||
c.env.ELYDORA_SIGNING_KEY,
|
||||
);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 202);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /v1/operations/:operation_id — Retrieve an operation
|
||||
// ---------------------------------------------------------------------------
|
||||
operations.get(
|
||||
'/:operation_id',
|
||||
authMiddleware,
|
||||
requireRole('readonly_investigator'),
|
||||
async (c) => {
|
||||
const operationId = c.req.param('operation_id');
|
||||
const orgId = c.get('org_id');
|
||||
|
||||
const result = await operationService.getOperation(c.env.ELYDORA_DB, c.env.ELYDORA_EVIDENCE, operationId, orgId);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/operations/:operation_id/verify — Verify operation integrity
|
||||
// ---------------------------------------------------------------------------
|
||||
operations.post(
|
||||
'/:operation_id/verify',
|
||||
authMiddleware,
|
||||
requireRole('readonly_investigator'),
|
||||
async (c) => {
|
||||
const operationId = c.req.param('operation_id');
|
||||
const orgId = c.get('org_id');
|
||||
|
||||
const result = await operationService.verifyOperation(
|
||||
c.env.ELYDORA_DB,
|
||||
c.env.ELYDORA_EVIDENCE,
|
||||
operationId,
|
||||
orgId,
|
||||
);
|
||||
|
||||
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
|
||||
return c.json(result, 200);
|
||||
},
|
||||
);
|
||||
|
||||
export { operations };
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Epoch scheduler — runs every 5 minutes to create Merkle tree rollups.
|
||||
*
|
||||
* Run as a separate Node.js process: node dist/scheduler.js
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import cron from 'node-cron';
|
||||
import { PostgresAdapter } from './adapters/postgres.js';
|
||||
import { MinioAdapter } from './adapters/minio.js';
|
||||
import { RedisCacheAdapter } from './adapters/redis-cache.js';
|
||||
import { BullMQAdapter } from './adapters/bullmq.js';
|
||||
import { createEpoch } from './services/epoch-service.js';
|
||||
import { DEFAULT_EPOCH_INTERVAL_MS } from './shared/index.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialize adapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const db = new PostgresAdapter(process.env.DATABASE_URL!);
|
||||
const evidence = new MinioAdapter(
|
||||
process.env.MINIO_ENDPOINT!,
|
||||
process.env.MINIO_ACCESS_KEY!,
|
||||
process.env.MINIO_SECRET_KEY!,
|
||||
process.env.MINIO_BUCKET ?? 'elydora-evidence',
|
||||
);
|
||||
const cache = new RedisCacheAdapter(process.env.REDIS_URL!);
|
||||
const redisUrl = new URL(process.env.REDIS_URL!);
|
||||
const queue = new BullMQAdapter({
|
||||
host: redisUrl.hostname,
|
||||
port: Number(redisUrl.port) || 6379,
|
||||
});
|
||||
|
||||
await cache.connect();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Epoch creation logic (mirrors the Cloudflare scheduled handler)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function runEpochCreation(): Promise<void> {
|
||||
const now = Date.now();
|
||||
const interval = DEFAULT_EPOCH_INTERVAL_MS;
|
||||
const epochEnd = Math.floor(now / interval) * interval;
|
||||
const epochStart = epochEnd - interval;
|
||||
|
||||
const { results: orgs } = await db
|
||||
.prepare(
|
||||
'SELECT DISTINCT org_id FROM operations WHERE created_at >= ? AND created_at < ?',
|
||||
)
|
||||
.bind(epochStart, epochEnd)
|
||||
.all<{ org_id: string }>();
|
||||
|
||||
if (!orgs || orgs.length === 0) return;
|
||||
|
||||
for (const { org_id } of orgs) {
|
||||
try {
|
||||
const epoch = await createEpoch(
|
||||
db,
|
||||
evidence,
|
||||
process.env.ELYDORA_SIGNING_KEY!,
|
||||
org_id,
|
||||
epochStart,
|
||||
epochEnd,
|
||||
);
|
||||
|
||||
if (epoch) {
|
||||
try {
|
||||
await queue.send({
|
||||
type: 'tsa',
|
||||
epoch_id: epoch.epoch_id,
|
||||
org_id: epoch.org_id,
|
||||
root_hash: epoch.root_hash,
|
||||
r2_epoch_key: epoch.r2_epoch_key,
|
||||
});
|
||||
} catch (tsaError) {
|
||||
console.error(
|
||||
`Failed to enqueue TSA anchor for epoch ${epoch.epoch_id}:`,
|
||||
tsaError instanceof Error ? tsaError.message : String(tsaError),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to create epoch for org ${org_id}:`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schedule: every 5 minutes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
cron.schedule('*/5 * * * *', async () => {
|
||||
console.log(`[${new Date().toISOString()}] Running epoch creation...`);
|
||||
try {
|
||||
await runEpochCreation();
|
||||
} catch (error) {
|
||||
console.error('Epoch creation failed:', error);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Elydora epoch scheduler started (every 5 minutes)');
|
||||
|
||||
// Graceful shutdown
|
||||
async function shutdown() {
|
||||
console.log('Shutting down scheduler...');
|
||||
await cache.close();
|
||||
await queue.close();
|
||||
await db.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
@@ -0,0 +1,545 @@
|
||||
/**
|
||||
* Agent service — business logic for agent lifecycle management.
|
||||
*
|
||||
* Handles registration, lookup, freezing, and key revocation. All
|
||||
* administrative mutations are recorded as admin_events for audit.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Agent,
|
||||
AgentKey,
|
||||
RegisterAgentRequest,
|
||||
RegisterAgentResponse,
|
||||
GetAgentResponse,
|
||||
ListAgentsResponse,
|
||||
AdminAction,
|
||||
} from '../shared/index.js';
|
||||
import { generateUUIDv7 } from '../utils/uuid.js';
|
||||
import { base64urlDecode } from '../utils/crypto.js';
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
import type { Database, PreparedStatement } from '../adapters/interfaces.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Register agent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function registerAgent(
|
||||
db: Database,
|
||||
body: RegisterAgentRequest,
|
||||
orgId: string,
|
||||
actor: string,
|
||||
): Promise<RegisterAgentResponse> {
|
||||
const now = Date.now();
|
||||
|
||||
// Check if agent already exists
|
||||
const existing = await db
|
||||
.prepare('SELECT agent_id FROM agents WHERE agent_id = ?')
|
||||
.bind(body.agent_id)
|
||||
.first<{ agent_id: string }>();
|
||||
|
||||
if (existing) {
|
||||
throw new AppError(409, 'VALIDATION_ERROR', { key: 'agent.alreadyExists', params: { id: body.agent_id } });
|
||||
}
|
||||
|
||||
// Validate at least one key is provided
|
||||
if (!body.keys || body.keys.length === 0) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.atLeastOneKey' });
|
||||
}
|
||||
|
||||
// Validate each public key is a valid 32-byte Ed25519 key
|
||||
for (const k of body.keys) {
|
||||
if (k.algorithm !== 'ed25519') {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.unsupportedAlgorithm', params: { algorithm: k.algorithm } });
|
||||
}
|
||||
try {
|
||||
const keyBytes = base64urlDecode(k.public_key);
|
||||
if (keyBytes.length !== 32) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.invalidPublicKeyLength', params: { kid: k.kid, expected: 32, actual: keyBytes.length } });
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof AppError) throw e;
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.invalidPublicKeyEncoding', params: { kid: k.kid } });
|
||||
}
|
||||
}
|
||||
|
||||
// Build the agent record
|
||||
const agent: Agent = {
|
||||
agent_id: body.agent_id,
|
||||
org_id: orgId,
|
||||
display_name: body.display_name ?? body.agent_id,
|
||||
responsible_entity: body.responsible_entity ?? '',
|
||||
integration_type: body.integration_type ?? 'sdk',
|
||||
status: 'active',
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
// Build key records
|
||||
const keys: AgentKey[] = body.keys.map((k) => ({
|
||||
kid: k.kid,
|
||||
agent_id: body.agent_id,
|
||||
public_key: k.public_key,
|
||||
algorithm: k.algorithm,
|
||||
status: 'active' as const,
|
||||
created_at: now,
|
||||
retired_at: null,
|
||||
}));
|
||||
|
||||
// Use a batch to insert atomically
|
||||
const statements: PreparedStatement[] = [];
|
||||
|
||||
// Insert agent
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO agents (agent_id, org_id, display_name, responsible_entity, integration_type, status, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
agent.agent_id,
|
||||
agent.org_id,
|
||||
agent.display_name,
|
||||
agent.responsible_entity,
|
||||
agent.integration_type,
|
||||
agent.status,
|
||||
agent.created_at,
|
||||
agent.updated_at,
|
||||
),
|
||||
);
|
||||
|
||||
// Insert keys
|
||||
for (const key of keys) {
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO agent_keys (kid, agent_id, public_key, algorithm, status, created_at, retired_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
key.kid,
|
||||
key.agent_id,
|
||||
key.public_key,
|
||||
key.algorithm,
|
||||
key.status,
|
||||
key.created_at,
|
||||
key.retired_at,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Insert admin event
|
||||
const eventId = generateUUIDv7();
|
||||
const action: AdminAction = 'agent.register';
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO admin_events (event_id, org_id, actor, action, target_type, target_id, details, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
eventId,
|
||||
orgId,
|
||||
actor,
|
||||
action,
|
||||
'agent',
|
||||
body.agent_id,
|
||||
JSON.stringify({ display_name: agent.display_name, integration_type: agent.integration_type, key_count: keys.length }),
|
||||
now,
|
||||
),
|
||||
);
|
||||
|
||||
await db.batch(statements);
|
||||
|
||||
return { agent, keys };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List agents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function listAgents(
|
||||
db: Database,
|
||||
orgId: string,
|
||||
): Promise<ListAgentsResponse> {
|
||||
const result = await db
|
||||
.prepare('SELECT * FROM agents WHERE org_id = ? ORDER BY created_at DESC')
|
||||
.bind(orgId)
|
||||
.all<Agent>();
|
||||
|
||||
return { agents: result.results ?? [] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Get agent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function getAgent(
|
||||
db: Database,
|
||||
agentId: string,
|
||||
orgId: string,
|
||||
): Promise<GetAgentResponse> {
|
||||
const agentRow = await db
|
||||
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
|
||||
.bind(agentId, orgId)
|
||||
.first<Agent>();
|
||||
|
||||
if (!agentRow) {
|
||||
throw new AppError(404, 'NOT_FOUND', { key: 'agent.notFound', params: { id: agentId } });
|
||||
}
|
||||
|
||||
const keysResult = await db
|
||||
.prepare('SELECT * FROM agent_keys WHERE agent_id = ?')
|
||||
.bind(agentId)
|
||||
.all<AgentKey>();
|
||||
|
||||
const keys = keysResult.results ?? [];
|
||||
|
||||
return { agent: agentRow, keys };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Update agent integration type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VALID_INTEGRATION_TYPES = [
|
||||
'sdk', 'claudecode', 'cursor', 'gemini', 'kirocli', 'kiroide',
|
||||
'opencode', 'copilot', 'letta', 'codex', 'kimi',
|
||||
'enterprise', 'gui', 'other',
|
||||
] as const;
|
||||
|
||||
export async function updateAgentIntegrationType(
|
||||
db: Database,
|
||||
agentId: string,
|
||||
integrationValue: string,
|
||||
orgId: string,
|
||||
actor: string,
|
||||
): Promise<{ agent: Agent }> {
|
||||
const now = Date.now();
|
||||
|
||||
const agentRow = await db
|
||||
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
|
||||
.bind(agentId, orgId)
|
||||
.first<Agent>();
|
||||
|
||||
if (!agentRow) {
|
||||
throw new AppError(404, 'NOT_FOUND', { key: 'agent.notFound', params: { id: agentId } });
|
||||
}
|
||||
|
||||
if (!(VALID_INTEGRATION_TYPES as readonly string[]).includes(integrationValue)) {
|
||||
throw new AppError(
|
||||
400,
|
||||
'VALIDATION_ERROR',
|
||||
{ key: 'agent.invalidIntegrationType', params: { value: integrationValue, valid: VALID_INTEGRATION_TYPES.join(', ') } },
|
||||
);
|
||||
}
|
||||
|
||||
const statements: PreparedStatement[] = [];
|
||||
|
||||
statements.push(
|
||||
db
|
||||
.prepare('UPDATE agents SET integration_type = ?, updated_at = ? WHERE agent_id = ?')
|
||||
.bind(integrationValue, now, agentId),
|
||||
);
|
||||
|
||||
const eventId = generateUUIDv7();
|
||||
const action: AdminAction = 'agent.update';
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO admin_events (event_id, org_id, actor, action, target_type, target_id, details, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
eventId,
|
||||
orgId,
|
||||
actor,
|
||||
action,
|
||||
'agent',
|
||||
agentId,
|
||||
JSON.stringify({ integration_type: integrationValue, previous_integration_type: agentRow.integration_type }),
|
||||
now,
|
||||
),
|
||||
);
|
||||
|
||||
await db.batch(statements);
|
||||
|
||||
const updatedAgent: Agent = {
|
||||
...agentRow,
|
||||
integration_type: integrationValue,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
return { agent: updatedAgent };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Freeze agent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function freezeAgent(
|
||||
db: Database,
|
||||
agentId: string,
|
||||
reason: string,
|
||||
orgId: string,
|
||||
actor: string,
|
||||
): Promise<{ agent: Agent }> {
|
||||
const now = Date.now();
|
||||
|
||||
// Look up the agent
|
||||
const agentRow = await db
|
||||
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
|
||||
.bind(agentId, orgId)
|
||||
.first<Agent>();
|
||||
|
||||
if (!agentRow) {
|
||||
throw new AppError(404, 'NOT_FOUND', { key: 'agent.notFound', params: { id: agentId } });
|
||||
}
|
||||
|
||||
if (agentRow.status === 'frozen') {
|
||||
throw new AppError(409, 'VALIDATION_ERROR', { key: 'agent.alreadyFrozen', params: { id: agentId } });
|
||||
}
|
||||
|
||||
if (agentRow.status === 'revoked') {
|
||||
throw new AppError(409, 'VALIDATION_ERROR', { key: 'agent.permanentlyRevoked', params: { id: agentId } });
|
||||
}
|
||||
|
||||
const statements: PreparedStatement[] = [];
|
||||
|
||||
// Update agent status
|
||||
statements.push(
|
||||
db
|
||||
.prepare('UPDATE agents SET status = ?, updated_at = ? WHERE agent_id = ?')
|
||||
.bind('frozen', now, agentId),
|
||||
);
|
||||
|
||||
// Log admin event
|
||||
const eventId = generateUUIDv7();
|
||||
const action: AdminAction = 'agent.freeze';
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO admin_events (event_id, org_id, actor, action, target_type, target_id, details, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(eventId, orgId, actor, action, 'agent', agentId, JSON.stringify({ reason }), now),
|
||||
);
|
||||
|
||||
await db.batch(statements);
|
||||
|
||||
const updatedAgent: Agent = {
|
||||
...agentRow,
|
||||
status: 'frozen',
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
return { agent: updatedAgent };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unfreeze agent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function unfreezeAgent(
|
||||
db: Database,
|
||||
agentId: string,
|
||||
reason: string,
|
||||
orgId: string,
|
||||
actor: string,
|
||||
): Promise<{ agent: Agent }> {
|
||||
const now = Date.now();
|
||||
|
||||
const agentRow = await db
|
||||
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
|
||||
.bind(agentId, orgId)
|
||||
.first<Agent>();
|
||||
|
||||
if (!agentRow) {
|
||||
throw new AppError(404, 'NOT_FOUND', { key: 'agent.notFound', params: { id: agentId } });
|
||||
}
|
||||
|
||||
if (agentRow.status === 'active') {
|
||||
throw new AppError(409, 'VALIDATION_ERROR', { key: 'agent.notFrozen', params: { id: agentId } });
|
||||
}
|
||||
|
||||
if (agentRow.status === 'revoked') {
|
||||
throw new AppError(409, 'VALIDATION_ERROR', { key: 'agent.permanentlyRevokedCannotUnfreeze', params: { id: agentId } });
|
||||
}
|
||||
|
||||
const statements: PreparedStatement[] = [];
|
||||
|
||||
statements.push(
|
||||
db
|
||||
.prepare('UPDATE agents SET status = ?, updated_at = ? WHERE agent_id = ?')
|
||||
.bind('active', now, agentId),
|
||||
);
|
||||
|
||||
const eventId = generateUUIDv7();
|
||||
const action: AdminAction = 'agent.unfreeze';
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO admin_events (event_id, org_id, actor, action, target_type, target_id, details, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(eventId, orgId, actor, action, 'agent', agentId, JSON.stringify({ reason }), now),
|
||||
);
|
||||
|
||||
await db.batch(statements);
|
||||
|
||||
const updatedAgent: Agent = {
|
||||
...agentRow,
|
||||
status: 'active',
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
return { agent: updatedAgent };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Delete agent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function deleteAgent(
|
||||
db: Database,
|
||||
agentId: string,
|
||||
orgId: string,
|
||||
actor: string,
|
||||
): Promise<void> {
|
||||
const now = Date.now();
|
||||
|
||||
// Verify the agent belongs to this org
|
||||
const agentRow = await db
|
||||
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
|
||||
.bind(agentId, orgId)
|
||||
.first<Agent>();
|
||||
|
||||
if (!agentRow) {
|
||||
throw new AppError(404, 'NOT_FOUND', { key: 'agent.notFound', params: { id: agentId } });
|
||||
}
|
||||
|
||||
const statements: PreparedStatement[] = [];
|
||||
|
||||
// Delete receipts for this agent's operations (receipts FK → operations)
|
||||
statements.push(
|
||||
db.prepare(
|
||||
'DELETE FROM receipts WHERE operation_id IN (SELECT operation_id FROM operations WHERE agent_id = ?)',
|
||||
).bind(agentId),
|
||||
);
|
||||
|
||||
// Delete operations (operations FK → agents)
|
||||
statements.push(
|
||||
db.prepare('DELETE FROM operations WHERE agent_id = ?').bind(agentId),
|
||||
);
|
||||
|
||||
// Delete associated keys (agent_keys FK → agents)
|
||||
statements.push(
|
||||
db.prepare('DELETE FROM agent_keys WHERE agent_id = ?').bind(agentId),
|
||||
);
|
||||
|
||||
// Delete the agent
|
||||
statements.push(
|
||||
db.prepare('DELETE FROM agents WHERE agent_id = ? AND org_id = ?').bind(agentId, orgId),
|
||||
);
|
||||
|
||||
// Log admin event
|
||||
const eventId = generateUUIDv7();
|
||||
const action: AdminAction = 'agent.delete';
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO admin_events (event_id, org_id, actor, action, target_type, target_id, details, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
eventId,
|
||||
orgId,
|
||||
actor,
|
||||
action,
|
||||
'agent',
|
||||
agentId,
|
||||
JSON.stringify({ display_name: agentRow.display_name }),
|
||||
now,
|
||||
),
|
||||
);
|
||||
|
||||
await db.batch(statements);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Revoke key
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function revokeKey(
|
||||
db: Database,
|
||||
agentId: string,
|
||||
kid: string,
|
||||
reason: string,
|
||||
orgId: string,
|
||||
actor: string,
|
||||
): Promise<{ key: AgentKey }> {
|
||||
const now = Date.now();
|
||||
|
||||
// Verify the agent belongs to this org
|
||||
const agentRow = await db
|
||||
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
|
||||
.bind(agentId, orgId)
|
||||
.first<Agent>();
|
||||
|
||||
if (!agentRow) {
|
||||
throw new AppError(404, 'NOT_FOUND', { key: 'agent.notFound', params: { id: agentId } });
|
||||
}
|
||||
|
||||
// Look up the key
|
||||
const keyRow = await db
|
||||
.prepare('SELECT * FROM agent_keys WHERE kid = ? AND agent_id = ?')
|
||||
.bind(kid, agentId)
|
||||
.first<AgentKey>();
|
||||
|
||||
if (!keyRow) {
|
||||
throw new AppError(404, 'NOT_FOUND', { key: 'agent.keyNotFound', params: { kid, id: agentId } });
|
||||
}
|
||||
|
||||
if (keyRow.status === 'revoked') {
|
||||
throw new AppError(409, 'VALIDATION_ERROR', { key: 'agent.keyAlreadyRevoked', params: { kid } });
|
||||
}
|
||||
|
||||
const statements: PreparedStatement[] = [];
|
||||
|
||||
// Revoke the key
|
||||
statements.push(
|
||||
db
|
||||
.prepare('UPDATE agent_keys SET status = ?, retired_at = ? WHERE kid = ?')
|
||||
.bind('revoked', now, kid),
|
||||
);
|
||||
|
||||
// Log admin event
|
||||
const eventId = generateUUIDv7();
|
||||
const action: AdminAction = 'key.revoke';
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO admin_events (event_id, org_id, actor, action, target_type, target_id, details, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
eventId,
|
||||
orgId,
|
||||
actor,
|
||||
action,
|
||||
'agent_key',
|
||||
kid,
|
||||
JSON.stringify({ agent_id: agentId, reason }),
|
||||
now,
|
||||
),
|
||||
);
|
||||
|
||||
await db.batch(statements);
|
||||
|
||||
const updatedKey: AgentKey = {
|
||||
...keyRow,
|
||||
status: 'revoked',
|
||||
retired_at: now,
|
||||
};
|
||||
|
||||
return { key: updatedKey };
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Audit service — business logic for querying the tamper-evident audit log.
|
||||
*
|
||||
* Builds dynamic SQL queries with optional filters (org_id, agent_id,
|
||||
* operation_type, time range) and supports cursor-based pagination for
|
||||
* efficient traversal of large result sets.
|
||||
*/
|
||||
|
||||
import type { Operation, AuditQueryRequest, AuditQueryResponse } from '../shared/index.js';
|
||||
import { DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT } from '../shared/index.js';
|
||||
import { decodeCursor, encodeCursor } from '../utils/pagination.js';
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
import type { Database } from '../adapters/interfaces.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query audit log
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function queryAudit(
|
||||
db: Database,
|
||||
params: AuditQueryRequest,
|
||||
orgId: string,
|
||||
): Promise<AuditQueryResponse> {
|
||||
// Validate and clamp limit
|
||||
const limit = Math.min(
|
||||
Math.max(params.limit ?? DEFAULT_QUERY_LIMIT, 1),
|
||||
MAX_QUERY_LIMIT,
|
||||
);
|
||||
|
||||
// Build WHERE clauses and bind values
|
||||
const conditions: string[] = [];
|
||||
const bindings: (string | number)[] = [];
|
||||
|
||||
// Always scope to the caller's org
|
||||
conditions.push('org_id = ?');
|
||||
bindings.push(orgId);
|
||||
|
||||
// Optional agent filter (further restrict if the request specifies an org_id matching theirs)
|
||||
if (params.agent_id) {
|
||||
conditions.push('agent_id = ?');
|
||||
bindings.push(params.agent_id);
|
||||
}
|
||||
|
||||
if (params.operation_type) {
|
||||
conditions.push('operation_type = ?');
|
||||
bindings.push(params.operation_type);
|
||||
}
|
||||
|
||||
if (params.start_time !== undefined && params.start_time !== null) {
|
||||
if (typeof params.start_time !== 'number' || params.start_time < 0) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'audit.invalidStartTime' });
|
||||
}
|
||||
conditions.push('created_at >= ?');
|
||||
bindings.push(params.start_time);
|
||||
}
|
||||
|
||||
if (params.end_time !== undefined && params.end_time !== null) {
|
||||
if (typeof params.end_time !== 'number' || params.end_time < 0) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'audit.invalidEndTime' });
|
||||
}
|
||||
conditions.push('created_at <= ?');
|
||||
bindings.push(params.end_time);
|
||||
}
|
||||
|
||||
// Cursor-based pagination (keyset pagination)
|
||||
let cursorPayload = params.cursor ? decodeCursor(params.cursor) : null;
|
||||
if (params.cursor && !cursorPayload) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'audit.invalidCursor' });
|
||||
}
|
||||
|
||||
if (cursorPayload) {
|
||||
// For descending order: fetch rows where (created_at, operation_id) < cursor
|
||||
conditions.push('(created_at < ? OR (created_at = ? AND operation_id < ?))');
|
||||
bindings.push(cursorPayload.created_at, cursorPayload.created_at, cursorPayload.id);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
|
||||
|
||||
// Get total count (without cursor filter for accurate total)
|
||||
const countConditions = conditions.filter(
|
||||
(_, i) => !cursorPayload || i < conditions.length - 1,
|
||||
);
|
||||
const countBindings = cursorPayload ? bindings.slice(0, -3) : [...bindings];
|
||||
|
||||
const countWhereClause =
|
||||
countConditions.length > 0 ? 'WHERE ' + countConditions.join(' AND ') : '';
|
||||
|
||||
const countQuery = `SELECT COUNT(*) as total FROM operations ${countWhereClause}`;
|
||||
const countStmt = db.prepare(countQuery);
|
||||
const boundCountStmt =
|
||||
countBindings.length > 0 ? countStmt.bind(...countBindings) : countStmt;
|
||||
const countResult = await boundCountStmt.first<{ total: number }>();
|
||||
const totalCount = countResult?.total ?? 0;
|
||||
|
||||
// Fetch the page of results (reverse chronological)
|
||||
// Request limit+1 to detect if there are more pages
|
||||
const dataQuery = `SELECT * FROM operations ${whereClause} ORDER BY created_at DESC, operation_id DESC LIMIT ?`;
|
||||
const dataBindings = [...bindings, limit + 1];
|
||||
|
||||
const dataStmt = db.prepare(dataQuery);
|
||||
const boundDataStmt = dataStmt.bind(...dataBindings);
|
||||
const dataResult = await boundDataStmt.all<Operation>();
|
||||
const rows = dataResult.results ?? [];
|
||||
|
||||
// Determine next cursor
|
||||
const hasMore = rows.length > limit;
|
||||
const operations = hasMore ? rows.slice(0, limit) : rows;
|
||||
|
||||
let nextCursor: string | undefined;
|
||||
if (hasMore && operations.length > 0) {
|
||||
const lastItem = operations[operations.length - 1]!;
|
||||
nextCursor = encodeCursor({
|
||||
created_at: lastItem.created_at,
|
||||
id: lastItem.operation_id,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
operations,
|
||||
cursor: nextCursor,
|
||||
total_count: totalCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Auth service — password hashing, JWT issuance, user registration, and login.
|
||||
*
|
||||
* Uses PBKDF2-SHA256 via Web Crypto API for password hashing and
|
||||
* HMAC-SHA256 for JWT issuance (matching the existing auth middleware).
|
||||
*/
|
||||
|
||||
import { base64urlEncode } from '../utils/crypto.js';
|
||||
import { generateUUIDv7 } from '../utils/uuid.js';
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
import { DEFAULT_JWT_TTL_SECONDS } from '../shared/index.js';
|
||||
import type { RbacRole } from '../shared/index.js';
|
||||
import type { Database } from '../adapters/interfaces.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row types (query results)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface UserRow {
|
||||
readonly user_id: string;
|
||||
readonly org_id: string;
|
||||
readonly email: string;
|
||||
readonly display_name: string;
|
||||
readonly role: RbacRole;
|
||||
readonly status: 'active' | 'suspended';
|
||||
readonly created_at: number;
|
||||
readonly updated_at: number;
|
||||
}
|
||||
|
||||
export interface OrgRow {
|
||||
readonly org_id: string;
|
||||
readonly name: string;
|
||||
readonly created_at: number;
|
||||
readonly updated_at: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JWT payload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface JWTPayload {
|
||||
sub: string;
|
||||
org_id: string;
|
||||
role: RbacRole;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Password hashing (PBKDF2-SHA256 via Web Crypto)
|
||||
// Format: pbkdf2:100000:salt_hex:hash_hex
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const encoder = new TextEncoder();
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits'],
|
||||
);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{ name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' },
|
||||
keyMaterial,
|
||||
256,
|
||||
);
|
||||
const saltHex = Array.from(salt)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
const hashHex = Array.from(new Uint8Array(bits))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
return `pbkdf2:100000:${saltHex}:${hashHex}`;
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
||||
const parts = stored.split(':');
|
||||
if (parts.length !== 4 || parts[0] !== 'pbkdf2') return false;
|
||||
|
||||
const iterations = parseInt(parts[1]!, 10);
|
||||
const saltHex = parts[2]!;
|
||||
const storedHashHex = parts[3]!;
|
||||
|
||||
const salt = new Uint8Array(saltHex.match(/.{2}/g)!.map((b) => parseInt(b, 16)));
|
||||
const encoder = new TextEncoder();
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(password),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits'],
|
||||
);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
|
||||
keyMaterial,
|
||||
256,
|
||||
);
|
||||
const hashHex = Array.from(new Uint8Array(bits))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
// Constant-time comparison
|
||||
if (hashHex.length !== storedHashHex.length) return false;
|
||||
let result = 0;
|
||||
for (let i = 0; i < hashHex.length; i++) {
|
||||
result |= hashHex.charCodeAt(i) ^ storedHashHex.charCodeAt(i);
|
||||
}
|
||||
return result === 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JWT issuance (HMAC-SHA256, matching existing auth middleware verification)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function issueJWT(payload: JWTPayload, secret: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const header = { alg: 'HS256', typ: 'JWT' };
|
||||
const headerB64 = base64urlEncode(encoder.encode(JSON.stringify(header)));
|
||||
const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload)));
|
||||
|
||||
const signingInput = `${headerB64}.${payloadB64}`;
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign'],
|
||||
);
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(signingInput));
|
||||
const signatureB64 = base64urlEncode(new Uint8Array(signature));
|
||||
|
||||
return `${headerB64}.${payloadB64}.${signatureB64}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Issue API token with custom TTL
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Maximum API token lifetime: 365 days. */
|
||||
const MAX_API_TOKEN_TTL = 365 * 24 * 60 * 60;
|
||||
|
||||
export async function issueApiToken(
|
||||
sub: string,
|
||||
orgId: string,
|
||||
role: RbacRole,
|
||||
jwtSecret: string,
|
||||
ttlSeconds: number | null,
|
||||
): Promise<{ token: string; expires_at: number }> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
// Default to 90 days if no TTL specified; cap at 365 days
|
||||
const effectiveTtl = ttlSeconds === null ? 90 * 24 * 60 * 60 : Math.min(ttlSeconds, MAX_API_TOKEN_TTL);
|
||||
if (effectiveTtl <= 0) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'auth.ttlMustBePositive' });
|
||||
}
|
||||
const exp = now + effectiveTtl;
|
||||
const expiresAt = exp;
|
||||
|
||||
const token = await issueJWT(
|
||||
{ sub, org_id: orgId, role, iat: now, exp },
|
||||
jwtSecret,
|
||||
);
|
||||
|
||||
return { token, expires_at: expiresAt };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Register user + org
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function registerUser(
|
||||
db: Database,
|
||||
jwtSecret: string,
|
||||
email: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
orgName: string,
|
||||
): Promise<{ user: UserRow; organization: OrgRow; token: string }> {
|
||||
// Validate
|
||||
if (!email || !password || password.length < 8 || password.length > 128) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'auth.emailPasswordRequired' });
|
||||
}
|
||||
|
||||
// Check email uniqueness
|
||||
const existing = await db
|
||||
.prepare('SELECT user_id FROM users WHERE email = ?')
|
||||
.bind(email)
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
throw new AppError(409, 'VALIDATION_ERROR', { key: 'auth.emailAlreadyRegistered' });
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const orgId = generateUUIDv7();
|
||||
const userId = generateUUIDv7();
|
||||
const hash = await hashPassword(password);
|
||||
|
||||
const resolvedDisplayName = displayName || email.split('@')[0]!;
|
||||
const resolvedOrgName = orgName || `${resolvedDisplayName}'s Organization`;
|
||||
|
||||
// Create org + user atomically in a single transaction
|
||||
await db.batch([
|
||||
db
|
||||
.prepare(
|
||||
'INSERT INTO organizations (org_id, name, created_at, updated_at) VALUES (?, ?, ?, ?)',
|
||||
)
|
||||
.bind(orgId, resolvedOrgName, now, now),
|
||||
db
|
||||
.prepare(
|
||||
'INSERT INTO users (user_id, org_id, email, password_hash, display_name, role, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
)
|
||||
.bind(userId, orgId, email, hash, resolvedDisplayName, 'org_owner', 'active', now, now),
|
||||
]);
|
||||
|
||||
const token = await issueJWT(
|
||||
{
|
||||
sub: userId,
|
||||
org_id: orgId,
|
||||
role: 'org_owner',
|
||||
iat: Math.floor(now / 1000),
|
||||
exp: Math.floor(now / 1000) + DEFAULT_JWT_TTL_SECONDS,
|
||||
},
|
||||
jwtSecret,
|
||||
);
|
||||
|
||||
return {
|
||||
user: {
|
||||
user_id: userId,
|
||||
org_id: orgId,
|
||||
email,
|
||||
display_name: resolvedDisplayName,
|
||||
role: 'org_owner' as RbacRole,
|
||||
status: 'active',
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
organization: {
|
||||
org_id: orgId,
|
||||
name: resolvedOrgName,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Login user
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function loginUser(
|
||||
db: Database,
|
||||
jwtSecret: string,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ user: UserRow; token: string }> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT user_id, org_id, email, password_hash, display_name, role, status, created_at, updated_at FROM users WHERE email = ?',
|
||||
)
|
||||
.bind(email)
|
||||
.first<UserRow & { password_hash: string }>();
|
||||
|
||||
if (!row) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.invalidCredentials' });
|
||||
}
|
||||
|
||||
if (row.status !== 'active') {
|
||||
throw new AppError(403, 'FORBIDDEN', { key: 'auth.accountSuspended' });
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(password, row.password_hash);
|
||||
if (!valid) {
|
||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.invalidCredentials' });
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const token = await issueJWT(
|
||||
{
|
||||
sub: row.user_id,
|
||||
org_id: row.org_id,
|
||||
role: row.role as RbacRole,
|
||||
iat: Math.floor(now / 1000),
|
||||
exp: Math.floor(now / 1000) + DEFAULT_JWT_TTL_SECONDS,
|
||||
},
|
||||
jwtSecret,
|
||||
);
|
||||
|
||||
return {
|
||||
user: {
|
||||
user_id: row.user_id,
|
||||
org_id: row.org_id,
|
||||
email: row.email,
|
||||
display_name: row.display_name,
|
||||
role: row.role as RbacRole,
|
||||
status: row.status,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
},
|
||||
token,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Get current user profile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function getMe(db: Database, userId: string): Promise<UserRow> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT user_id, org_id, email, display_name, role, status, created_at, updated_at FROM users WHERE user_id = ?',
|
||||
)
|
||||
.bind(userId)
|
||||
.first<UserRow>();
|
||||
|
||||
if (!row) {
|
||||
throw new AppError(404, 'NOT_FOUND', { key: 'auth.userNotFound' });
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Epoch service — business logic for retrieving epoch root records.
|
||||
*
|
||||
* Epochs are periodic Merkle-tree rollups that anchor a set of operations
|
||||
* to a single root hash. Optionally, the epoch may carry a TSA
|
||||
* (Timestamping Authority) anchor that binds the root to an external
|
||||
* trusted timestamp for non-repudiation.
|
||||
*/
|
||||
|
||||
import type { Epoch, EER, GetEpochResponse, ListEpochsResponse } from '../shared/index.js';
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
import { buildMerkleTree } from '../utils/merkle.js';
|
||||
import { generateUUIDv7 } from '../utils/uuid.js';
|
||||
import { signEd25519, jcsCanonicalise } from '../utils/crypto.js';
|
||||
import type { Database, ObjectStore } from '../adapters/interfaces.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List epochs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function listEpochs(
|
||||
db: Database,
|
||||
orgId: string,
|
||||
): Promise<ListEpochsResponse> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT * FROM epochs WHERE org_id = ? ORDER BY created_at DESC')
|
||||
.bind(orgId)
|
||||
.all<Epoch>();
|
||||
return { epochs: results ?? [] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Get epoch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function getEpoch(
|
||||
db: Database,
|
||||
r2: ObjectStore,
|
||||
epochId: string,
|
||||
orgId: string,
|
||||
): Promise<GetEpochResponse> {
|
||||
const epoch = await db
|
||||
.prepare('SELECT * FROM epochs WHERE epoch_id = ? AND org_id = ?')
|
||||
.bind(epochId, orgId)
|
||||
.first<Epoch>();
|
||||
|
||||
if (!epoch) {
|
||||
throw new AppError(404, 'NOT_FOUND', { key: 'epoch.notFound', params: { id: epochId } });
|
||||
}
|
||||
|
||||
// Attempt to load TSA anchor from R2 (stored alongside the epoch record)
|
||||
let anchor: { tsa_token?: string; tsa_url?: string; anchored_at?: number } | undefined;
|
||||
|
||||
try {
|
||||
const tsaKey = `${epoch.r2_epoch_key}/tsa-anchor`;
|
||||
const tsaObject = await r2.get(tsaKey);
|
||||
if (tsaObject) {
|
||||
const tsaData = JSON.parse(await tsaObject.text()) as {
|
||||
tsa_token?: string;
|
||||
tsa_url?: string;
|
||||
anchored_at?: number;
|
||||
};
|
||||
if (tsaData.tsa_token) {
|
||||
anchor = {
|
||||
tsa_token: tsaData.tsa_token,
|
||||
tsa_url: tsaData.tsa_url,
|
||||
anchored_at: tsaData.anchored_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// TSA anchor is optional; if it doesn't exist or fails to parse, omit it
|
||||
}
|
||||
|
||||
return {
|
||||
epoch,
|
||||
anchor,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create epoch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a new epoch by building a Merkle tree over operations in a time
|
||||
* window, signing the Epoch Record, and persisting to D1 + R2.
|
||||
*
|
||||
* Returns null if the epoch already exists, or if no operations fall within
|
||||
* the given time range.
|
||||
*/
|
||||
export async function createEpoch(
|
||||
db: Database,
|
||||
r2: ObjectStore,
|
||||
signingKey: string,
|
||||
orgId: string,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): Promise<Epoch | null> {
|
||||
// 1. Idempotency check — skip if epoch already exists for this window
|
||||
const existing = await db
|
||||
.prepare(
|
||||
'SELECT epoch_id FROM epochs WHERE org_id = ? AND start_time = ? AND end_time = ?',
|
||||
)
|
||||
.bind(orgId, startTime, endTime)
|
||||
.first<{ epoch_id: string }>();
|
||||
|
||||
if (existing) return null;
|
||||
|
||||
// 2. Query operations in the time window
|
||||
const { results: ops } = await db
|
||||
.prepare(
|
||||
'SELECT operation_id, chain_hash FROM operations WHERE org_id = ? AND created_at >= ? AND created_at < ? ORDER BY chain_hash ASC',
|
||||
)
|
||||
.bind(orgId, startTime, endTime)
|
||||
.all<{ operation_id: string; chain_hash: string }>();
|
||||
|
||||
if (!ops || ops.length === 0) return null;
|
||||
|
||||
// 3. Build Merkle tree
|
||||
const chainHashes = ops.map((op) => op.chain_hash);
|
||||
const operationIds = ops.map((op) => op.operation_id);
|
||||
const tree = await buildMerkleTree(chainHashes, operationIds);
|
||||
|
||||
// 4. Generate epoch ID
|
||||
const epochId = generateUUIDv7();
|
||||
|
||||
// 5. Create and sign the EER
|
||||
const eer: { -readonly [K in keyof EER]: EER[K] } = {
|
||||
epoch_id: epochId,
|
||||
org_id: orgId,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
leaf_count: tree.leaves.length,
|
||||
root_hash: tree.root,
|
||||
hash_alg: 'sha256',
|
||||
signature_by_elydora: '',
|
||||
};
|
||||
|
||||
const canonical = jcsCanonicalise(eer);
|
||||
eer.signature_by_elydora = await signEd25519(
|
||||
signingKey,
|
||||
new TextEncoder().encode(canonical),
|
||||
);
|
||||
|
||||
// 6. Store in R2
|
||||
const r2Key = `epochs/${orgId}/${epochId}`;
|
||||
const r2Data = {
|
||||
eer: eer as EER,
|
||||
merkle: { leafOps: tree.leafOps, layers: tree.layers },
|
||||
};
|
||||
await r2.put(r2Key, JSON.stringify(r2Data), {
|
||||
httpMetadata: { contentType: 'application/json' },
|
||||
});
|
||||
|
||||
// 7. Insert epoch record in D1
|
||||
const now = Date.now();
|
||||
await db
|
||||
.prepare(
|
||||
'INSERT INTO epochs (epoch_id, org_id, start_time, end_time, root_hash, leaf_count, r2_epoch_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
)
|
||||
.bind(epochId, orgId, startTime, endTime, tree.root, tree.leaves.length, r2Key, now)
|
||||
.run();
|
||||
|
||||
return {
|
||||
epoch_id: epochId,
|
||||
org_id: orgId,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
root_hash: tree.root,
|
||||
leaf_count: tree.leaves.length,
|
||||
r2_epoch_key: r2Key,
|
||||
created_at: now,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Export service — business logic for compliance export job management.
|
||||
*
|
||||
* Exports are asynchronous jobs that package operations matching specified
|
||||
* criteria into a downloadable bundle (JSON or PDF). The job is enqueued
|
||||
* via a message queue and processed by the queue consumer. Once complete,
|
||||
* the result is stored in the object store and the database record is
|
||||
* updated with a reference to the object key.
|
||||
*/
|
||||
|
||||
import type { Export, CreateExportRequest, CreateExportResponse, GetExportResponse, ListExportsResponse } from '../shared/index.js';
|
||||
import { generateUUIDv7 } from '../utils/uuid.js';
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
import type { Database, MessageQueue, ObjectStore } from '../adapters/interfaces.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createExport(
|
||||
db: Database,
|
||||
queue: MessageQueue,
|
||||
body: CreateExportRequest,
|
||||
orgId: string,
|
||||
actor: string,
|
||||
): Promise<CreateExportResponse> {
|
||||
const now = Date.now();
|
||||
|
||||
// Validate time range
|
||||
if (typeof body.start_time !== 'number' || body.start_time <= 0) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'export.invalidStartTime' });
|
||||
}
|
||||
if (typeof body.end_time !== 'number' || body.end_time <= 0) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'export.invalidEndTime' });
|
||||
}
|
||||
if (body.start_time >= body.end_time) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'export.startBeforeEnd' });
|
||||
}
|
||||
|
||||
// Validate format
|
||||
if (body.format !== 'json' && body.format !== 'pdf') {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'export.invalidFormat' });
|
||||
}
|
||||
|
||||
const exportId = generateUUIDv7();
|
||||
|
||||
const queryParams = JSON.stringify({
|
||||
start_time: body.start_time,
|
||||
end_time: body.end_time,
|
||||
agent_id: body.agent_id ?? null,
|
||||
operation_type: body.operation_type ?? null,
|
||||
format: body.format,
|
||||
});
|
||||
|
||||
const exportRecord: Export = {
|
||||
export_id: exportId,
|
||||
org_id: orgId,
|
||||
status: 'queued',
|
||||
query_params: queryParams,
|
||||
r2_export_key: null,
|
||||
created_at: now,
|
||||
completed_at: null,
|
||||
};
|
||||
|
||||
// Insert export record
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO exports (export_id, org_id, status, query_params, r2_export_key, created_at, completed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
exportRecord.export_id,
|
||||
exportRecord.org_id,
|
||||
exportRecord.status,
|
||||
exportRecord.query_params,
|
||||
exportRecord.r2_export_key,
|
||||
exportRecord.created_at,
|
||||
exportRecord.completed_at,
|
||||
)
|
||||
.run();
|
||||
|
||||
// Log admin event
|
||||
const eventId = generateUUIDv7();
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO admin_events (event_id, org_id, actor, action, target_type, target_id, details, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
eventId,
|
||||
orgId,
|
||||
actor,
|
||||
'export.create',
|
||||
'export',
|
||||
exportId,
|
||||
queryParams,
|
||||
now,
|
||||
)
|
||||
.run();
|
||||
|
||||
// Enqueue the export job
|
||||
await queue.send({
|
||||
type: 'export',
|
||||
export_id: exportId,
|
||||
org_id: orgId,
|
||||
query_params: queryParams,
|
||||
});
|
||||
|
||||
return { export: exportRecord };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function listExports(
|
||||
db: Database,
|
||||
orgId: string,
|
||||
): Promise<ListExportsResponse> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT * FROM exports WHERE org_id = ? ORDER BY created_at DESC')
|
||||
.bind(orgId)
|
||||
.all<Export>();
|
||||
return { exports: results ?? [] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Get export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function getExport(
|
||||
db: Database,
|
||||
r2: ObjectStore,
|
||||
exportId: string,
|
||||
orgId: string,
|
||||
): Promise<GetExportResponse> {
|
||||
const exportRecord = await db
|
||||
.prepare('SELECT * FROM exports WHERE export_id = ? AND org_id = ?')
|
||||
.bind(exportId, orgId)
|
||||
.first<Export>();
|
||||
|
||||
if (!exportRecord) {
|
||||
throw new AppError(404, 'NOT_FOUND', { key: 'export.notFound', params: { id: exportId } });
|
||||
}
|
||||
|
||||
let downloadUrl: string | undefined;
|
||||
|
||||
// If the export is complete and has an object store key, provide a download path.
|
||||
// The client can use a dedicated download endpoint to stream the file.
|
||||
if (exportRecord.status === 'done' && exportRecord.r2_export_key) {
|
||||
// Verify the object actually exists in R2
|
||||
const head = await r2.head(exportRecord.r2_export_key);
|
||||
if (head) {
|
||||
// Construct a download path that the client can use
|
||||
downloadUrl = `/v1/exports/${exportId}/download`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
export: exportRecord,
|
||||
download_url: downloadUrl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
/**
|
||||
* Operation service — business logic for EOR submission, retrieval, and verification.
|
||||
*
|
||||
* This is the core of the Elydora protocol implementation. It performs:
|
||||
* 1. EOR field validation
|
||||
* 2. Agent and key status checks
|
||||
* 3. Ed25519 signature verification
|
||||
* 4. Hash chain validation and computation
|
||||
* 5. Persistence to D1 and R2
|
||||
* 6. Queue enqueue for async processing
|
||||
* 7. EAR (receipt) generation with server signature
|
||||
*/
|
||||
|
||||
import type {
|
||||
EOR,
|
||||
EAR,
|
||||
Agent,
|
||||
AgentKey,
|
||||
Operation,
|
||||
Receipt,
|
||||
GetOperationResponse,
|
||||
VerifyOperationResponse,
|
||||
} from '../shared/index.js';
|
||||
import {
|
||||
MAX_PAYLOAD_SIZE,
|
||||
MAX_TTL_MS,
|
||||
MIN_TTL_MS,
|
||||
MAX_NONCE_LENGTH,
|
||||
} from '../shared/index.js';
|
||||
import {
|
||||
base64urlDecode,
|
||||
verifyEd25519Signature,
|
||||
jcsCanonicalise,
|
||||
computeChainHash,
|
||||
computeReceiptHash,
|
||||
signEd25519,
|
||||
} from '../utils/crypto.js';
|
||||
import { generateUUIDv7 } from '../utils/uuid.js';
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
import type { Database, ObjectStore, Cache, MessageQueue, PreparedStatement } from '../adapters/interfaces.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The genesis chain hash used for an agent's first operation. */
|
||||
const GENESIS_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
||||
|
||||
/** Elydora server key ID used in receipts. */
|
||||
const ELYDORA_KID = 'elydora-server-key-v1';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Submit operation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function submitOperation(
|
||||
db: Database,
|
||||
r2: ObjectStore,
|
||||
kv: Cache,
|
||||
queue: MessageQueue,
|
||||
eor: EOR,
|
||||
signingKey: string,
|
||||
): Promise<{ receipt: EAR }> {
|
||||
const receivedAt = Date.now();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 1: Validate EOR fields
|
||||
// ------------------------------------------------------------------
|
||||
validateEORFields(eor, receivedAt);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 2: Replay detection via nonce in KV
|
||||
// ------------------------------------------------------------------
|
||||
const nonceKey = `nonce:${eor.org_id}:${eor.nonce}`;
|
||||
const existingNonce = await kv.get(nonceKey);
|
||||
if (existingNonce) {
|
||||
throw new AppError(400, 'REPLAY_DETECTED');
|
||||
}
|
||||
// Store nonce with TTL equal to the operation's TTL (in seconds)
|
||||
const nonceTtlSeconds = Math.ceil(eor.ttl_ms / 1000);
|
||||
await kv.put(nonceKey, '1', { expirationTtl: nonceTtlSeconds });
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 3: Look up agent and check status
|
||||
// ------------------------------------------------------------------
|
||||
const agent = await db
|
||||
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
|
||||
.bind(eor.agent_id, eor.org_id)
|
||||
.first<Agent>();
|
||||
|
||||
if (!agent) {
|
||||
throw new AppError(404, 'UNKNOWN_AGENT', { key: 'operation.agentNotRegistered', params: { id: eor.agent_id } });
|
||||
}
|
||||
|
||||
if (agent.status === 'frozen') {
|
||||
throw new AppError(403, 'AGENT_FROZEN');
|
||||
}
|
||||
|
||||
if (agent.status === 'revoked') {
|
||||
throw new AppError(403, 'AGENT_FROZEN', { key: 'operation.agentRevoked' });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 4: Look up key and check status
|
||||
// ------------------------------------------------------------------
|
||||
const agentKey = await db
|
||||
.prepare('SELECT * FROM agent_keys WHERE kid = ? AND agent_id = ?')
|
||||
.bind(eor.agent_pubkey_kid, eor.agent_id)
|
||||
.first<AgentKey>();
|
||||
|
||||
if (!agentKey) {
|
||||
throw new AppError(400, 'INVALID_SIGNATURE', { key: 'operation.keyNotFoundForAgent', params: { kid: eor.agent_pubkey_kid, id: eor.agent_id } });
|
||||
}
|
||||
|
||||
if (agentKey.status === 'revoked') {
|
||||
throw new AppError(403, 'KEY_REVOKED');
|
||||
}
|
||||
|
||||
if (agentKey.status === 'retired') {
|
||||
throw new AppError(403, 'KEY_REVOKED', { key: 'operation.keyRetired' });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 5: Verify Ed25519 signature
|
||||
// ------------------------------------------------------------------
|
||||
const signableEOR = buildSignableEOR(eor);
|
||||
const canonicalData = jcsCanonicalise(signableEOR);
|
||||
const dataBytes = new TextEncoder().encode(canonicalData);
|
||||
const signatureBytes = base64urlDecode(eor.signature);
|
||||
const publicKeyBytes = base64urlDecode(agentKey.public_key);
|
||||
|
||||
const signatureValid = await verifyEd25519Signature(publicKeyBytes, signatureBytes, dataBytes);
|
||||
|
||||
if (!signatureValid) {
|
||||
throw new AppError(400, 'INVALID_SIGNATURE');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 6: Verify prev_chain_hash against the latest chain_hash
|
||||
// ------------------------------------------------------------------
|
||||
const latestOp = await db
|
||||
.prepare(
|
||||
'SELECT chain_hash, seq_no FROM operations WHERE agent_id = ? ORDER BY seq_no DESC LIMIT 1',
|
||||
)
|
||||
.bind(eor.agent_id)
|
||||
.first<{ chain_hash: string; seq_no: number }>();
|
||||
|
||||
const expectedPrevHash = latestOp ? latestOp.chain_hash : GENESIS_CHAIN_HASH;
|
||||
const nextSeqNo = latestOp ? latestOp.seq_no + 1 : 1;
|
||||
|
||||
if (eor.prev_chain_hash !== expectedPrevHash) {
|
||||
throw new AppError(400, 'PREV_HASH_MISMATCH', { key: 'operation.prevHashMismatch', params: { expected: expectedPrevHash, actual: eor.prev_chain_hash } });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 7: Compute new chain_hash
|
||||
// ------------------------------------------------------------------
|
||||
const chainHash = await computeChainHash(
|
||||
eor.prev_chain_hash,
|
||||
eor.payload_hash,
|
||||
eor.operation_id,
|
||||
eor.issued_at,
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 8: Store operation in D1
|
||||
// ------------------------------------------------------------------
|
||||
const r2PayloadKey = `${eor.org_id}/${eor.agent_id}/${eor.operation_id}`;
|
||||
|
||||
const operation: Operation = {
|
||||
operation_id: eor.operation_id,
|
||||
org_id: eor.org_id,
|
||||
agent_id: eor.agent_id,
|
||||
seq_no: nextSeqNo,
|
||||
operation_type: eor.operation_type,
|
||||
issued_at: eor.issued_at,
|
||||
ttl_ms: eor.ttl_ms,
|
||||
nonce: eor.nonce,
|
||||
subject: JSON.stringify(eor.subject),
|
||||
action: JSON.stringify(eor.action),
|
||||
payload_hash: eor.payload_hash,
|
||||
prev_chain_hash: eor.prev_chain_hash,
|
||||
chain_hash: chainHash,
|
||||
agent_pubkey_kid: eor.agent_pubkey_kid,
|
||||
signature: eor.signature,
|
||||
r2_payload_key: r2PayloadKey,
|
||||
created_at: receivedAt,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 9: Store the full EOR envelope in R2
|
||||
// ------------------------------------------------------------------
|
||||
const eorJson = JSON.stringify(eor);
|
||||
await r2.put(r2PayloadKey, eorJson, {
|
||||
httpMetadata: { contentType: 'application/json' },
|
||||
customMetadata: {
|
||||
org_id: eor.org_id,
|
||||
agent_id: eor.agent_id,
|
||||
operation_type: eor.operation_type,
|
||||
},
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 10: Enqueue for async processing
|
||||
// ------------------------------------------------------------------
|
||||
const queueMessageResult = await queue.send({
|
||||
type: 'operation',
|
||||
operation_id: eor.operation_id,
|
||||
org_id: eor.org_id,
|
||||
agent_id: eor.agent_id,
|
||||
});
|
||||
|
||||
// D1 batch insert: operation + receipt
|
||||
const receiptId = generateUUIDv7();
|
||||
const queueMessageId = typeof queueMessageResult === 'object' && queueMessageResult !== null
|
||||
? String((queueMessageResult as Record<string, unknown>).messageId ?? receiptId)
|
||||
: receiptId;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 11: Generate EAR receipt
|
||||
// ------------------------------------------------------------------
|
||||
const receiptFields = {
|
||||
receipt_version: '1.0',
|
||||
receipt_id: receiptId,
|
||||
operation_id: eor.operation_id,
|
||||
org_id: eor.org_id,
|
||||
agent_id: eor.agent_id,
|
||||
server_received_at: receivedAt,
|
||||
seq_no: nextSeqNo,
|
||||
chain_hash: chainHash,
|
||||
queue_message_id: queueMessageId,
|
||||
};
|
||||
|
||||
const receiptHash = await computeReceiptHash(receiptFields);
|
||||
|
||||
// Sign the receipt hash with the server key
|
||||
const receiptDataToSign = new TextEncoder().encode(receiptHash);
|
||||
const elydoraSignature = await signEd25519(signingKey, receiptDataToSign);
|
||||
|
||||
const ear: EAR = {
|
||||
...receiptFields,
|
||||
receipt_hash: receiptHash,
|
||||
elydora_kid: ELYDORA_KID,
|
||||
elydora_signature: elydoraSignature,
|
||||
};
|
||||
|
||||
// Store receipt in R2
|
||||
const r2ReceiptKey = `${eor.org_id}/${eor.agent_id}/receipts/${eor.operation_id}`;
|
||||
await r2.put(r2ReceiptKey, JSON.stringify(ear), {
|
||||
httpMetadata: { contentType: 'application/json' },
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 12: Persist operation and receipt to D1
|
||||
// ------------------------------------------------------------------
|
||||
const statements: PreparedStatement[] = [];
|
||||
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO operations (operation_id, org_id, agent_id, seq_no, operation_type, issued_at, ttl_ms, nonce, subject, action, payload_hash, prev_chain_hash, chain_hash, agent_pubkey_kid, signature, r2_payload_key, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
operation.operation_id,
|
||||
operation.org_id,
|
||||
operation.agent_id,
|
||||
operation.seq_no,
|
||||
operation.operation_type,
|
||||
operation.issued_at,
|
||||
operation.ttl_ms,
|
||||
operation.nonce,
|
||||
operation.subject,
|
||||
operation.action,
|
||||
operation.payload_hash,
|
||||
operation.prev_chain_hash,
|
||||
operation.chain_hash,
|
||||
operation.agent_pubkey_kid,
|
||||
operation.signature,
|
||||
operation.r2_payload_key,
|
||||
operation.created_at,
|
||||
),
|
||||
);
|
||||
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO receipts (receipt_id, operation_id, r2_receipt_key, created_at)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(receiptId, eor.operation_id, r2ReceiptKey, receivedAt),
|
||||
);
|
||||
|
||||
await db.batch(statements);
|
||||
|
||||
return { receipt: ear };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Get operation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function getOperation(
|
||||
db: Database,
|
||||
r2: ObjectStore,
|
||||
operationId: string,
|
||||
orgId: string,
|
||||
): Promise<GetOperationResponse> {
|
||||
const operation = await db
|
||||
.prepare('SELECT * FROM operations WHERE operation_id = ? AND org_id = ?')
|
||||
.bind(operationId, orgId)
|
||||
.first<Operation>();
|
||||
|
||||
if (!operation) {
|
||||
throw new AppError(404, 'NOT_FOUND', { key: 'operation.notFound', params: { id: operationId } });
|
||||
}
|
||||
|
||||
const receipt = await db
|
||||
.prepare('SELECT * FROM receipts WHERE operation_id = ?')
|
||||
.bind(operationId)
|
||||
.first<Receipt>();
|
||||
|
||||
// Fetch payload from R2
|
||||
let payload: Record<string, unknown> | undefined;
|
||||
if (operation.r2_payload_key) {
|
||||
try {
|
||||
const r2Object = await r2.get(operation.r2_payload_key);
|
||||
if (r2Object) {
|
||||
const eor = await r2Object.json<Record<string, unknown>>();
|
||||
payload = (eor.payload as Record<string, unknown>) ?? undefined;
|
||||
}
|
||||
} catch {
|
||||
// Payload fetch is best-effort — don't fail the request
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
operation,
|
||||
receipt: receipt ?? undefined,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verify operation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function verifyOperation(
|
||||
db: Database,
|
||||
r2: ObjectStore,
|
||||
operationId: string,
|
||||
orgId: string,
|
||||
): Promise<VerifyOperationResponse> {
|
||||
const errors: string[] = [];
|
||||
let signatureCheck = false;
|
||||
let chainCheck = false;
|
||||
let receiptCheck = false;
|
||||
|
||||
// Load the operation
|
||||
const operation = await db
|
||||
.prepare('SELECT * FROM operations WHERE operation_id = ? AND org_id = ?')
|
||||
.bind(operationId, orgId)
|
||||
.first<Operation>();
|
||||
|
||||
if (!operation) {
|
||||
throw new AppError(404, 'NOT_FOUND', { key: 'operation.notFound', params: { id: operationId } });
|
||||
}
|
||||
|
||||
// Load the EOR from R2
|
||||
if (!operation.r2_payload_key) {
|
||||
errors.push('Operation has no R2 payload key.');
|
||||
return { valid: false, checks: { signature: false, chain: false, receipt: false }, errors };
|
||||
}
|
||||
const eorObject = await r2.get(operation.r2_payload_key);
|
||||
if (!eorObject) {
|
||||
errors.push('EOR evidence not found in R2.');
|
||||
return { valid: false, checks: { signature: false, chain: false, receipt: false }, errors };
|
||||
}
|
||||
|
||||
let eor: EOR;
|
||||
try {
|
||||
eor = JSON.parse(await eorObject.text()) as EOR;
|
||||
} catch {
|
||||
errors.push('Failed to parse EOR evidence from R2.');
|
||||
return { valid: false, checks: { signature: false, chain: false, receipt: false }, errors };
|
||||
}
|
||||
|
||||
// ---- Signature verification ----
|
||||
try {
|
||||
const agentKey = await db
|
||||
.prepare('SELECT * FROM agent_keys WHERE kid = ? AND agent_id = ?')
|
||||
.bind(eor.agent_pubkey_kid, eor.agent_id)
|
||||
.first<AgentKey>();
|
||||
|
||||
if (agentKey) {
|
||||
const signableEOR = buildSignableEOR(eor);
|
||||
const canonicalData = jcsCanonicalise(signableEOR);
|
||||
const dataBytes = new TextEncoder().encode(canonicalData);
|
||||
const signatureBytes = base64urlDecode(eor.signature);
|
||||
const publicKeyBytes = base64urlDecode(agentKey.public_key);
|
||||
|
||||
signatureCheck = await verifyEd25519Signature(publicKeyBytes, signatureBytes, dataBytes);
|
||||
if (!signatureCheck) {
|
||||
errors.push('Ed25519 signature verification failed.');
|
||||
}
|
||||
} else {
|
||||
errors.push(`Signing key "${eor.agent_pubkey_kid}" not found.`);
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push(`Signature check error: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
// ---- Chain hash verification ----
|
||||
try {
|
||||
const expectedChainHash = await computeChainHash(
|
||||
operation.prev_chain_hash,
|
||||
operation.payload_hash,
|
||||
operation.operation_id,
|
||||
operation.issued_at,
|
||||
);
|
||||
|
||||
chainCheck = expectedChainHash === operation.chain_hash;
|
||||
if (!chainCheck) {
|
||||
errors.push(`Chain hash mismatch: expected "${expectedChainHash}", stored "${operation.chain_hash}".`);
|
||||
}
|
||||
|
||||
// Also verify the prev_chain_hash links correctly
|
||||
if (operation.seq_no > 1) {
|
||||
const prevOp = await db
|
||||
.prepare('SELECT chain_hash FROM operations WHERE agent_id = ? AND seq_no = ?')
|
||||
.bind(operation.agent_id, operation.seq_no - 1)
|
||||
.first<{ chain_hash: string }>();
|
||||
|
||||
if (prevOp && prevOp.chain_hash !== operation.prev_chain_hash) {
|
||||
chainCheck = false;
|
||||
errors.push(`prev_chain_hash does not match previous operation's chain_hash.`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push(`Chain check error: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
// ---- Receipt verification ----
|
||||
try {
|
||||
const receiptRow = await db
|
||||
.prepare('SELECT * FROM receipts WHERE operation_id = ?')
|
||||
.bind(operationId)
|
||||
.first<Receipt>();
|
||||
|
||||
if (receiptRow) {
|
||||
const receiptObject = await r2.get(receiptRow.r2_receipt_key);
|
||||
if (receiptObject) {
|
||||
const ear = JSON.parse(await receiptObject.text()) as EAR;
|
||||
|
||||
// Recompute receipt hash
|
||||
const receiptFields = {
|
||||
receipt_version: ear.receipt_version,
|
||||
receipt_id: ear.receipt_id,
|
||||
operation_id: ear.operation_id,
|
||||
org_id: ear.org_id,
|
||||
agent_id: ear.agent_id,
|
||||
server_received_at: ear.server_received_at,
|
||||
seq_no: ear.seq_no,
|
||||
chain_hash: ear.chain_hash,
|
||||
queue_message_id: ear.queue_message_id,
|
||||
};
|
||||
|
||||
const expectedHash = await computeReceiptHash(receiptFields);
|
||||
if (expectedHash === ear.receipt_hash) {
|
||||
receiptCheck = true;
|
||||
} else {
|
||||
errors.push('Receipt hash does not match recomputed value.');
|
||||
}
|
||||
} else {
|
||||
errors.push('Receipt evidence not found in R2.');
|
||||
}
|
||||
} else {
|
||||
errors.push('No receipt found for this operation.');
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push(`Receipt check error: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
|
||||
// ---- Merkle inclusion verification ----
|
||||
let merkleCheck: boolean | undefined;
|
||||
|
||||
// Find epoch by operation's created_at timestamp
|
||||
const epochRecord = await db
|
||||
.prepare('SELECT * FROM epochs WHERE org_id = ? AND start_time <= ? AND end_time > ?')
|
||||
.bind(orgId, operation.created_at, operation.created_at)
|
||||
.first<{ 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 }>();
|
||||
|
||||
if (epochRecord) {
|
||||
try {
|
||||
const epochObj = await r2.get(epochRecord.r2_epoch_key);
|
||||
if (epochObj) {
|
||||
const epochData = JSON.parse(await epochObj.text()) as {
|
||||
merkle: { leafOps: string[]; layers: string[][] };
|
||||
};
|
||||
// Check if operation's chain_hash is in the leaf layer
|
||||
const leaves = epochData.merkle.layers[0];
|
||||
if (leaves && leaves.includes(operation.chain_hash)) {
|
||||
// Verify the tree root matches the epoch's root_hash
|
||||
const { getMerkleProof, verifyMerkleProof } = await import('../utils/merkle.js');
|
||||
const tree = {
|
||||
root: epochRecord.root_hash,
|
||||
leaves,
|
||||
leafOps: epochData.merkle.leafOps,
|
||||
layers: epochData.merkle.layers,
|
||||
};
|
||||
const proof = getMerkleProof(tree, operation.chain_hash);
|
||||
if (proof) {
|
||||
merkleCheck = await verifyMerkleProof(proof);
|
||||
if (!merkleCheck) {
|
||||
errors.push('Merkle inclusion proof verification failed.');
|
||||
}
|
||||
} else {
|
||||
merkleCheck = false;
|
||||
errors.push('Operation chain_hash found in leaves but proof generation failed.');
|
||||
}
|
||||
} else {
|
||||
merkleCheck = false;
|
||||
errors.push('Operation chain_hash not found in epoch Merkle tree leaves.');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If R2 read fails, leave merkle as undefined (pending)
|
||||
}
|
||||
}
|
||||
// If no epoch found, merkleCheck remains undefined (pending)
|
||||
|
||||
const valid = signatureCheck && chainCheck && receiptCheck;
|
||||
|
||||
return {
|
||||
valid,
|
||||
checks: {
|
||||
signature: signatureCheck,
|
||||
chain: chainCheck,
|
||||
receipt: receiptCheck,
|
||||
...(merkleCheck !== undefined ? { merkle: merkleCheck } : {}),
|
||||
},
|
||||
errors: errors.length > 0 ? errors : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build the signable portion of an EOR (excludes the signature field itself).
|
||||
*/
|
||||
function buildSignableEOR(eor: EOR): Record<string, unknown> {
|
||||
return {
|
||||
op_version: eor.op_version,
|
||||
operation_id: eor.operation_id,
|
||||
org_id: eor.org_id,
|
||||
agent_id: eor.agent_id,
|
||||
issued_at: eor.issued_at,
|
||||
ttl_ms: eor.ttl_ms,
|
||||
nonce: eor.nonce,
|
||||
operation_type: eor.operation_type,
|
||||
subject: eor.subject,
|
||||
action: eor.action,
|
||||
payload: eor.payload,
|
||||
payload_hash: eor.payload_hash,
|
||||
prev_chain_hash: eor.prev_chain_hash,
|
||||
agent_pubkey_kid: eor.agent_pubkey_kid,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all required EOR fields before processing.
|
||||
*/
|
||||
function validateEORFields(eor: EOR, receivedAt: number): void {
|
||||
// Protocol version
|
||||
if (eor.op_version !== '1.0') {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.unsupportedVersion', params: { version: eor.op_version } });
|
||||
}
|
||||
|
||||
// Required string fields
|
||||
const requiredStrings: Array<[string, unknown]> = [
|
||||
['operation_id', eor.operation_id],
|
||||
['org_id', eor.org_id],
|
||||
['agent_id', eor.agent_id],
|
||||
['nonce', eor.nonce],
|
||||
['operation_type', eor.operation_type],
|
||||
['payload_hash', eor.payload_hash],
|
||||
['prev_chain_hash', eor.prev_chain_hash],
|
||||
['agent_pubkey_kid', eor.agent_pubkey_kid],
|
||||
['signature', eor.signature],
|
||||
];
|
||||
|
||||
for (const [field, value] of requiredStrings) {
|
||||
if (!value || typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.missingField', params: { field } });
|
||||
}
|
||||
}
|
||||
|
||||
// Nonce length
|
||||
if (eor.nonce.length > MAX_NONCE_LENGTH) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.nonceTooLong', params: { max: MAX_NONCE_LENGTH } });
|
||||
}
|
||||
|
||||
// issued_at must be a number
|
||||
if (typeof eor.issued_at !== 'number' || eor.issued_at <= 0) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.invalidIssuedAt' });
|
||||
}
|
||||
|
||||
// TTL bounds
|
||||
if (typeof eor.ttl_ms !== 'number') {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.missingTtl' });
|
||||
}
|
||||
if (eor.ttl_ms < MIN_TTL_MS) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.ttlTooLow', params: { min: MIN_TTL_MS } });
|
||||
}
|
||||
if (eor.ttl_ms > MAX_TTL_MS) {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.ttlTooHigh', params: { max: MAX_TTL_MS } });
|
||||
}
|
||||
|
||||
// TTL expiration check
|
||||
if (eor.issued_at + eor.ttl_ms < receivedAt) {
|
||||
throw new AppError(400, 'TTL_EXPIRED');
|
||||
}
|
||||
|
||||
// Payload size check (approximate via JSON serialization)
|
||||
if (eor.payload !== null && eor.payload !== undefined) {
|
||||
const payloadStr = typeof eor.payload === 'string' ? eor.payload : JSON.stringify(eor.payload);
|
||||
if (new TextEncoder().encode(payloadStr).byteLength > MAX_PAYLOAD_SIZE) {
|
||||
throw new AppError(400, 'PAYLOAD_TOO_LARGE');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ErrorCode } from '../types/enums.js';
|
||||
|
||||
/** Map of all Elydora error codes to default human-readable messages */
|
||||
export const ERROR_CODES: Readonly<Record<ErrorCode, string>> = {
|
||||
INVALID_SIGNATURE: 'The operation signature is invalid.',
|
||||
UNKNOWN_AGENT: 'The specified agent does not exist.',
|
||||
KEY_REVOKED: 'The signing key has been revoked.',
|
||||
AGENT_FROZEN: 'The agent is frozen and cannot submit operations.',
|
||||
TTL_EXPIRED: 'The operation TTL has expired.',
|
||||
REPLAY_DETECTED: 'A duplicate nonce was detected (replay attack).',
|
||||
PREV_HASH_MISMATCH: 'The previous chain hash does not match.',
|
||||
PAYLOAD_TOO_LARGE: 'The operation payload exceeds the maximum allowed size.',
|
||||
RATE_LIMITED: 'Too many requests. Please retry later.',
|
||||
INTERNAL_ERROR: 'An internal server error occurred.',
|
||||
UNAUTHORIZED: 'Authentication is required.',
|
||||
FORBIDDEN: 'You do not have permission to perform this action.',
|
||||
NOT_FOUND: 'The requested resource was not found.',
|
||||
VALIDATION_ERROR: 'The request failed validation.',
|
||||
} as const;
|
||||
@@ -0,0 +1,13 @@
|
||||
export { ERROR_CODES } from './errors.js';
|
||||
export {
|
||||
MAX_PAYLOAD_SIZE,
|
||||
MAX_TTL_MS,
|
||||
MIN_TTL_MS,
|
||||
MAX_NONCE_LENGTH,
|
||||
STARTER_MAX_AGENTS,
|
||||
DEFAULT_RETENTION_DAYS,
|
||||
DEFAULT_EPOCH_INTERVAL_MS,
|
||||
MAX_QUERY_LIMIT,
|
||||
DEFAULT_QUERY_LIMIT,
|
||||
DEFAULT_JWT_TTL_SECONDS,
|
||||
} from './limits.js';
|
||||
@@ -0,0 +1,29 @@
|
||||
/** Maximum payload size in bytes (256 KB) */
|
||||
export const MAX_PAYLOAD_SIZE = 256 * 1024;
|
||||
|
||||
/** Maximum TTL in milliseconds (5 minutes) */
|
||||
export const MAX_TTL_MS = 300_000;
|
||||
|
||||
/** Minimum TTL in milliseconds (1 second) */
|
||||
export const MIN_TTL_MS = 1_000;
|
||||
|
||||
/** Maximum nonce length in characters */
|
||||
export const MAX_NONCE_LENGTH = 64;
|
||||
|
||||
/** Maximum number of agents on the Starter plan */
|
||||
export const STARTER_MAX_AGENTS = 10;
|
||||
|
||||
/** Default data retention period in days */
|
||||
export const DEFAULT_RETENTION_DAYS = 30;
|
||||
|
||||
/** Default epoch interval in milliseconds (5 minutes) */
|
||||
export const DEFAULT_EPOCH_INTERVAL_MS = 300_000;
|
||||
|
||||
/** Maximum number of results per query */
|
||||
export const MAX_QUERY_LIMIT = 1000;
|
||||
|
||||
/** Default number of results per query */
|
||||
export const DEFAULT_QUERY_LIMIT = 100;
|
||||
|
||||
/** Default JWT token lifetime in seconds (24 hours) */
|
||||
export const DEFAULT_JWT_TTL_SECONDS = 86400;
|
||||
@@ -0,0 +1,66 @@
|
||||
export type {
|
||||
AgentStatus,
|
||||
KeyStatus,
|
||||
ExportStatus,
|
||||
AdminAction,
|
||||
RbacRole,
|
||||
ErrorCode,
|
||||
EOR,
|
||||
ECH,
|
||||
EAR,
|
||||
EER,
|
||||
Agent,
|
||||
AgentKey,
|
||||
Operation,
|
||||
Receipt,
|
||||
Epoch,
|
||||
AdminEvent,
|
||||
Export,
|
||||
Organization,
|
||||
User,
|
||||
RegisterAgentRequest,
|
||||
RegisterAgentResponse,
|
||||
GetAgentResponse,
|
||||
ListAgentsResponse,
|
||||
FreezeAgentRequest,
|
||||
UnfreezeAgentRequest,
|
||||
RevokeAgentRequest,
|
||||
UpdateAgentRequest,
|
||||
SubmitOperationRequest,
|
||||
SubmitOperationResponse,
|
||||
GetOperationResponse,
|
||||
VerifyOperationResponse,
|
||||
AuditQueryRequest,
|
||||
AuditQueryResponse,
|
||||
GetEpochResponse,
|
||||
ListEpochsResponse,
|
||||
CreateExportRequest,
|
||||
CreateExportResponse,
|
||||
GetExportResponse,
|
||||
ListExportsResponse,
|
||||
AuthRegisterRequest,
|
||||
AuthRegisterResponse,
|
||||
AuthLoginRequest,
|
||||
AuthLoginResponse,
|
||||
AuthMeResponse,
|
||||
AuthRefreshResponse,
|
||||
IssueTokenRequest,
|
||||
IssueTokenResponse,
|
||||
JWK,
|
||||
JWKSResponse,
|
||||
ErrorResponse,
|
||||
} from './types/index.js';
|
||||
|
||||
export {
|
||||
ERROR_CODES,
|
||||
MAX_PAYLOAD_SIZE,
|
||||
MAX_TTL_MS,
|
||||
MIN_TTL_MS,
|
||||
MAX_NONCE_LENGTH,
|
||||
STARTER_MAX_AGENTS,
|
||||
DEFAULT_RETENTION_DAYS,
|
||||
DEFAULT_EPOCH_INTERVAL_MS,
|
||||
MAX_QUERY_LIMIT,
|
||||
DEFAULT_QUERY_LIMIT,
|
||||
DEFAULT_JWT_TTL_SECONDS,
|
||||
} from './constants/index.js';
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { Agent, AgentKey, Epoch, Export, Operation, Organization, Receipt, User } from './entities.js';
|
||||
import type { ErrorCode } from './enums.js';
|
||||
import type { EAR, EOR } from './protocol.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface RegisterAgentRequest {
|
||||
readonly agent_id: string;
|
||||
readonly display_name?: string;
|
||||
readonly responsible_entity?: string;
|
||||
readonly integration_type?: string;
|
||||
readonly keys: ReadonlyArray<{
|
||||
readonly kid: string;
|
||||
readonly public_key: string;
|
||||
readonly algorithm: 'ed25519';
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RegisterAgentResponse {
|
||||
readonly agent: Agent;
|
||||
readonly keys: AgentKey[];
|
||||
}
|
||||
|
||||
export interface GetAgentResponse {
|
||||
readonly agent: Agent;
|
||||
readonly keys: AgentKey[];
|
||||
}
|
||||
|
||||
export interface ListAgentsResponse {
|
||||
readonly agents: Agent[];
|
||||
}
|
||||
|
||||
export interface FreezeAgentRequest {
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
export interface UnfreezeAgentRequest {
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
export interface RevokeAgentRequest {
|
||||
readonly kid: string;
|
||||
readonly reason: string;
|
||||
}
|
||||
|
||||
export interface UpdateAgentRequest {
|
||||
readonly integration_type: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operation endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SubmitOperationRequest = EOR;
|
||||
|
||||
export interface SubmitOperationResponse {
|
||||
readonly receipt: EAR;
|
||||
}
|
||||
|
||||
export interface GetOperationResponse {
|
||||
readonly operation: Operation;
|
||||
readonly receipt?: Receipt;
|
||||
readonly payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface VerifyOperationResponse {
|
||||
readonly valid: boolean;
|
||||
readonly checks: {
|
||||
readonly signature: boolean;
|
||||
readonly chain: boolean;
|
||||
readonly receipt: boolean;
|
||||
readonly merkle?: boolean;
|
||||
};
|
||||
readonly errors?: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audit query endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AuditQueryRequest {
|
||||
readonly org_id?: string;
|
||||
readonly agent_id?: string;
|
||||
readonly operation_type?: string;
|
||||
readonly start_time?: number;
|
||||
readonly end_time?: number;
|
||||
readonly cursor?: string;
|
||||
readonly limit?: number;
|
||||
}
|
||||
|
||||
export interface AuditQueryResponse {
|
||||
readonly operations: Operation[];
|
||||
readonly cursor?: string;
|
||||
readonly total_count: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Epoch endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface GetEpochResponse {
|
||||
readonly epoch: Epoch;
|
||||
readonly anchor?: {
|
||||
readonly tsa_token?: string;
|
||||
readonly tsa_url?: string;
|
||||
readonly anchored_at?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ListEpochsResponse {
|
||||
readonly epochs: Epoch[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface CreateExportRequest {
|
||||
readonly start_time: number;
|
||||
readonly end_time: number;
|
||||
readonly agent_id?: string;
|
||||
readonly operation_type?: string;
|
||||
readonly format: 'json' | 'pdf';
|
||||
}
|
||||
|
||||
export interface CreateExportResponse {
|
||||
readonly export: Export;
|
||||
}
|
||||
|
||||
export interface GetExportResponse {
|
||||
readonly export: Export;
|
||||
readonly download_url?: string;
|
||||
}
|
||||
|
||||
export interface ListExportsResponse {
|
||||
readonly exports: Export[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JWKS endpoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface JWK {
|
||||
readonly kty: string;
|
||||
readonly crv?: string;
|
||||
readonly x?: string;
|
||||
readonly kid: string;
|
||||
readonly use: string;
|
||||
readonly alg: string;
|
||||
}
|
||||
|
||||
export interface JWKSResponse {
|
||||
readonly keys: JWK[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AuthRegisterRequest {
|
||||
readonly email: string;
|
||||
readonly password: string;
|
||||
readonly display_name?: string;
|
||||
readonly org_name?: string;
|
||||
}
|
||||
|
||||
export interface AuthRegisterResponse {
|
||||
readonly user: User;
|
||||
readonly organization: Organization;
|
||||
readonly token: string;
|
||||
}
|
||||
|
||||
export interface AuthLoginRequest {
|
||||
readonly email: string;
|
||||
readonly password: string;
|
||||
}
|
||||
|
||||
export interface AuthLoginResponse {
|
||||
readonly user: User;
|
||||
readonly token: string;
|
||||
}
|
||||
|
||||
export interface AuthMeResponse {
|
||||
readonly user: User;
|
||||
}
|
||||
|
||||
export interface AuthRefreshResponse {
|
||||
readonly token: string;
|
||||
}
|
||||
|
||||
export interface IssueTokenRequest {
|
||||
readonly ttl_seconds?: number | null; // null = never expire
|
||||
}
|
||||
|
||||
export interface IssueTokenResponse {
|
||||
readonly token: string;
|
||||
readonly expires_at: number | null; // null = never expire
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ErrorResponse {
|
||||
readonly error: {
|
||||
readonly code: ErrorCode;
|
||||
readonly message: string;
|
||||
readonly request_id: string;
|
||||
readonly details?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { AgentStatus, ExportStatus, KeyStatus, RbacRole } from './enums.js';
|
||||
|
||||
/** Registered AI agent within an organization */
|
||||
export interface Agent {
|
||||
readonly agent_id: string;
|
||||
readonly org_id: string;
|
||||
readonly display_name: string;
|
||||
readonly responsible_entity: string;
|
||||
readonly integration_type: string;
|
||||
readonly status: AgentStatus;
|
||||
readonly created_at: number;
|
||||
readonly updated_at: number;
|
||||
}
|
||||
|
||||
/** Public key associated with an agent for signing operations */
|
||||
export interface AgentKey {
|
||||
readonly kid: string;
|
||||
readonly agent_id: string;
|
||||
/** Base64-encoded public key (stored as BLOB in D1) */
|
||||
readonly public_key: string;
|
||||
readonly algorithm: 'ed25519';
|
||||
readonly status: KeyStatus;
|
||||
readonly created_at: number;
|
||||
readonly retired_at: number | null;
|
||||
}
|
||||
|
||||
/** Persisted operation record in D1 */
|
||||
export interface Operation {
|
||||
readonly operation_id: string;
|
||||
readonly org_id: string;
|
||||
readonly agent_id: string;
|
||||
readonly seq_no: number;
|
||||
readonly operation_type: string;
|
||||
readonly issued_at: number;
|
||||
readonly ttl_ms: number;
|
||||
readonly nonce: string;
|
||||
readonly subject: string;
|
||||
readonly action: string;
|
||||
readonly payload_hash: string;
|
||||
readonly prev_chain_hash: string;
|
||||
readonly chain_hash: string;
|
||||
readonly agent_pubkey_kid: string;
|
||||
readonly signature: string;
|
||||
readonly r2_payload_key: string | null;
|
||||
readonly created_at: number;
|
||||
}
|
||||
|
||||
/** Receipt stored in R2, referenced from D1 */
|
||||
export interface Receipt {
|
||||
readonly receipt_id: string;
|
||||
readonly operation_id: string;
|
||||
readonly r2_receipt_key: string;
|
||||
readonly created_at: number;
|
||||
}
|
||||
|
||||
/** Periodic Merkle epoch rollup record */
|
||||
export interface Epoch {
|
||||
readonly epoch_id: string;
|
||||
readonly org_id: string;
|
||||
readonly start_time: number;
|
||||
readonly end_time: number;
|
||||
readonly root_hash: string;
|
||||
readonly leaf_count: number;
|
||||
readonly r2_epoch_key: string;
|
||||
readonly created_at: number;
|
||||
}
|
||||
|
||||
/** Admin audit event log entry */
|
||||
export interface AdminEvent {
|
||||
readonly event_id: string;
|
||||
readonly org_id: string;
|
||||
readonly actor: string;
|
||||
readonly action: string;
|
||||
readonly target_type: string;
|
||||
readonly target_id: string;
|
||||
readonly details: string | null;
|
||||
readonly created_at: number;
|
||||
}
|
||||
|
||||
/** Organization */
|
||||
export interface Organization {
|
||||
readonly org_id: string;
|
||||
readonly name: string;
|
||||
readonly created_at: number;
|
||||
readonly updated_at: number;
|
||||
}
|
||||
|
||||
/** Console user within an organization */
|
||||
export interface User {
|
||||
readonly user_id: string;
|
||||
readonly org_id: string;
|
||||
readonly email: string;
|
||||
readonly display_name: string;
|
||||
readonly role: RbacRole;
|
||||
readonly status: 'active' | 'suspended';
|
||||
readonly created_at: number;
|
||||
readonly updated_at: number;
|
||||
}
|
||||
|
||||
/** Compliance export job */
|
||||
export interface Export {
|
||||
readonly export_id: string;
|
||||
readonly org_id: string;
|
||||
readonly status: ExportStatus;
|
||||
readonly query_params: string;
|
||||
readonly r2_export_key: string | null;
|
||||
readonly created_at: number;
|
||||
readonly completed_at: number | null;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/** Agent lifecycle status */
|
||||
export type AgentStatus = 'active' | 'frozen' | 'revoked';
|
||||
|
||||
/** Agent key lifecycle status */
|
||||
export type KeyStatus = 'active' | 'retired' | 'revoked';
|
||||
|
||||
/** Export job status */
|
||||
export type ExportStatus = 'queued' | 'running' | 'done' | 'failed';
|
||||
|
||||
/** Administrative action types for audit logging */
|
||||
export type AdminAction =
|
||||
| 'agent.register'
|
||||
| 'agent.update'
|
||||
| 'agent.freeze'
|
||||
| 'agent.unfreeze'
|
||||
| 'agent.revoke'
|
||||
| 'agent.delete'
|
||||
| 'key.revoke'
|
||||
| 'export.create';
|
||||
|
||||
/** RBAC roles for console access */
|
||||
export type RbacRole =
|
||||
| 'org_owner'
|
||||
| 'security_admin'
|
||||
| 'compliance_auditor'
|
||||
| 'readonly_investigator'
|
||||
| 'integration_engineer';
|
||||
|
||||
/** Error codes returned by the API */
|
||||
export type ErrorCode =
|
||||
| 'INVALID_SIGNATURE'
|
||||
| 'UNKNOWN_AGENT'
|
||||
| 'KEY_REVOKED'
|
||||
| 'AGENT_FROZEN'
|
||||
| 'TTL_EXPIRED'
|
||||
| 'REPLAY_DETECTED'
|
||||
| 'PREV_HASH_MISMATCH'
|
||||
| 'PAYLOAD_TOO_LARGE'
|
||||
| 'RATE_LIMITED'
|
||||
| 'INTERNAL_ERROR'
|
||||
| 'UNAUTHORIZED'
|
||||
| 'FORBIDDEN'
|
||||
| 'NOT_FOUND'
|
||||
| 'VALIDATION_ERROR';
|
||||
@@ -0,0 +1,61 @@
|
||||
export type {
|
||||
AgentStatus,
|
||||
KeyStatus,
|
||||
ExportStatus,
|
||||
AdminAction,
|
||||
RbacRole,
|
||||
ErrorCode,
|
||||
} from './enums.js';
|
||||
|
||||
export type {
|
||||
EOR,
|
||||
ECH,
|
||||
EAR,
|
||||
EER,
|
||||
} from './protocol.js';
|
||||
|
||||
export type {
|
||||
Agent,
|
||||
AgentKey,
|
||||
Operation,
|
||||
Receipt,
|
||||
Epoch,
|
||||
AdminEvent,
|
||||
Export,
|
||||
Organization,
|
||||
User,
|
||||
} from './entities.js';
|
||||
|
||||
export type {
|
||||
RegisterAgentRequest,
|
||||
RegisterAgentResponse,
|
||||
GetAgentResponse,
|
||||
ListAgentsResponse,
|
||||
FreezeAgentRequest,
|
||||
UnfreezeAgentRequest,
|
||||
RevokeAgentRequest,
|
||||
UpdateAgentRequest,
|
||||
SubmitOperationRequest,
|
||||
SubmitOperationResponse,
|
||||
GetOperationResponse,
|
||||
VerifyOperationResponse,
|
||||
AuditQueryRequest,
|
||||
AuditQueryResponse,
|
||||
GetEpochResponse,
|
||||
ListEpochsResponse,
|
||||
CreateExportRequest,
|
||||
CreateExportResponse,
|
||||
GetExportResponse,
|
||||
ListExportsResponse,
|
||||
AuthRegisterRequest,
|
||||
AuthRegisterResponse,
|
||||
AuthLoginRequest,
|
||||
AuthLoginResponse,
|
||||
AuthMeResponse,
|
||||
AuthRefreshResponse,
|
||||
IssueTokenRequest,
|
||||
IssueTokenResponse,
|
||||
JWK,
|
||||
JWKSResponse,
|
||||
ErrorResponse,
|
||||
} from './api.js';
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Elydora Operation Record (EOR)
|
||||
*
|
||||
* The fundamental unit of auditable activity in the Elydora protocol.
|
||||
* Signed by the agent's Ed25519 key and chained via prev_chain_hash.
|
||||
*/
|
||||
export interface EOR {
|
||||
/** Protocol version, always "1.0" */
|
||||
readonly op_version: '1.0';
|
||||
/** Unique operation identifier (UUIDv7) */
|
||||
readonly operation_id: string;
|
||||
/** Organization identifier */
|
||||
readonly org_id: string;
|
||||
/** Agent that issued this operation */
|
||||
readonly agent_id: string;
|
||||
/** Timestamp when issued (Unix milliseconds) */
|
||||
readonly issued_at: number;
|
||||
/** Time-to-live in milliseconds */
|
||||
readonly ttl_ms: number;
|
||||
/** Replay-prevention nonce (base64url) */
|
||||
readonly nonce: string;
|
||||
/** Classification of the operation */
|
||||
readonly operation_type: string;
|
||||
/** Subject of the operation */
|
||||
readonly subject: Record<string, unknown>;
|
||||
/** Action performed */
|
||||
readonly action: Record<string, unknown>;
|
||||
/** Operation payload */
|
||||
readonly payload: Record<string, unknown> | string | null;
|
||||
/** SHA-256 hash of the payload (base64url) */
|
||||
readonly payload_hash: string;
|
||||
/** Hash of the previous operation in the agent's chain (base64url) */
|
||||
readonly prev_chain_hash: string;
|
||||
/** Key ID of the agent's signing key */
|
||||
readonly agent_pubkey_kid: string;
|
||||
/** Ed25519 signature over the canonical EOR (base64url) */
|
||||
readonly signature: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Elydora Chain Hash (ECH)
|
||||
*
|
||||
* Represents the chain hash computation linking operations together.
|
||||
*/
|
||||
export interface ECH {
|
||||
/** Previous chain hash (base64url) */
|
||||
readonly prev_ech: string;
|
||||
/** SHA-256 hash of the operation payload (base64url) */
|
||||
readonly payload_hash: string;
|
||||
/** Operation identifier (UUIDv7) */
|
||||
readonly operation_id: string;
|
||||
/** Timestamp when issued (Unix milliseconds) */
|
||||
readonly issued_at: number;
|
||||
/** Resulting chain hash (base64url) */
|
||||
readonly chain_hash: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Elydora Acknowledgment Receipt (EAR)
|
||||
*
|
||||
* Server-issued receipt confirming acceptance of an operation.
|
||||
*/
|
||||
export interface EAR {
|
||||
/** Receipt version */
|
||||
readonly receipt_version: string;
|
||||
/** Unique receipt identifier */
|
||||
readonly receipt_id: string;
|
||||
/** Operation this receipt acknowledges */
|
||||
readonly operation_id: string;
|
||||
/** Organization identifier */
|
||||
readonly org_id: string;
|
||||
/** Agent that submitted the operation */
|
||||
readonly agent_id: string;
|
||||
/** Timestamp when the server received the operation (Unix ms) */
|
||||
readonly server_received_at: number;
|
||||
/** Sequence number assigned to the operation */
|
||||
readonly seq_no: number;
|
||||
/** Chain hash at time of receipt */
|
||||
readonly chain_hash: string;
|
||||
/** Queue message ID for async processing */
|
||||
readonly queue_message_id: string;
|
||||
/** Hash of this receipt */
|
||||
readonly receipt_hash: string;
|
||||
/** Elydora server key ID used for signing */
|
||||
readonly elydora_kid: string;
|
||||
/** Elydora server signature over the receipt */
|
||||
readonly elydora_signature: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Elydora Epoch Record (EER)
|
||||
*
|
||||
* Periodic Merkle-tree rollup anchoring operations to a root hash.
|
||||
*/
|
||||
export interface EER {
|
||||
/** Unique epoch identifier */
|
||||
readonly epoch_id: string;
|
||||
/** Organization identifier */
|
||||
readonly org_id: string;
|
||||
/** Epoch start time (Unix ms) */
|
||||
readonly start_time: number;
|
||||
/** Epoch end time (Unix ms) */
|
||||
readonly end_time: number;
|
||||
/** Number of leaf operations in this epoch */
|
||||
readonly leaf_count: number;
|
||||
/** Merkle root hash */
|
||||
readonly root_hash: string;
|
||||
/** Hash algorithm used (e.g. "sha256") */
|
||||
readonly hash_alg: string;
|
||||
/** Elydora server signature over the epoch record */
|
||||
readonly signature_by_elydora: string;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Environment bindings for the ElydoraOpenSource Node.js server.
|
||||
*
|
||||
* Replaces Cloudflare-specific bindings (D1Database, R2Bucket, KVNamespace,
|
||||
* Queue) with adapter interfaces backed by PostgreSQL, MinIO, Redis, and
|
||||
* BullMQ respectively.
|
||||
*/
|
||||
|
||||
import type { Database, ObjectStore, Cache, MessageQueue } from './adapters/interfaces.js';
|
||||
|
||||
export interface Env {
|
||||
/** PostgreSQL adapter for relational storage */
|
||||
ELYDORA_DB: Database;
|
||||
|
||||
/** MinIO/S3 adapter for evidence payloads, receipts, and export bundles */
|
||||
ELYDORA_EVIDENCE: ObjectStore;
|
||||
|
||||
/** Redis adapter for caching (nonces, rate-limit counters, etc.) */
|
||||
ELYDORA_CACHE: Cache;
|
||||
|
||||
/** BullMQ adapter for async operation processing and export jobs */
|
||||
ELYDORA_QUEUE: MessageQueue;
|
||||
|
||||
/** Deployment environment identifier (e.g. "production", "staging") */
|
||||
ENVIRONMENT: string;
|
||||
|
||||
/** Current API version (e.g. "v1") */
|
||||
API_VERSION: string;
|
||||
|
||||
/** Current Elydora protocol version (e.g. "1.0") */
|
||||
PROTOCOL_VERSION: string;
|
||||
|
||||
/** HMAC secret used for JWT verification */
|
||||
JWT_SECRET: string;
|
||||
|
||||
/** Base64url-encoded Ed25519 private key used by the server to sign receipts */
|
||||
ELYDORA_SIGNING_KEY: string;
|
||||
|
||||
/** Comma-separated list of allowed CORS origins (used in production) */
|
||||
ALLOWED_ORIGINS: string;
|
||||
|
||||
/** Optional TSA endpoint URL (defaults to Sectigo if unset) */
|
||||
TSA_URL?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Variables set by middleware and available via `c.get(...)` / `c.var`.
|
||||
*/
|
||||
export interface AppVariables {
|
||||
/** UUIDv7 request identifier injected by the request-id middleware */
|
||||
request_id: string;
|
||||
|
||||
/** Organization identifier extracted from the verified JWT */
|
||||
org_id: string;
|
||||
|
||||
/** RBAC role extracted from the verified JWT */
|
||||
role: import('./shared/index.js').RbacRole;
|
||||
|
||||
/** Actor identifier (sub claim) extracted from the verified JWT */
|
||||
actor: string;
|
||||
|
||||
/** Detected language from Accept-Language header (set by i18n middleware) */
|
||||
lang: import('./i18n/messages.js').Lang;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Cryptographic utilities for the Elydora API.
|
||||
*
|
||||
* All operations use the Web Crypto API (SubtleCrypto) which is available
|
||||
* natively in Node.js 20+ with full Ed25519 support.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base64url helpers (RFC 4648 section 5)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Decode a base64url string to a Uint8Array. */
|
||||
export function base64urlDecode(input: string): Uint8Array {
|
||||
// Convert base64url to standard base64
|
||||
let base64 = input.replace(/-/g, '+').replace(/_/g, '/');
|
||||
// Pad to a multiple of 4
|
||||
while (base64.length % 4 !== 0) {
|
||||
base64 += '=';
|
||||
}
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Encode a Uint8Array to a base64url string (no padding). */
|
||||
export function base64urlEncode(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]!);
|
||||
}
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SHA-256
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Compute SHA-256 of arbitrary data and return base64url. */
|
||||
export async function sha256Base64url(data: BufferSource | string): Promise<string> {
|
||||
const input = typeof data === 'string' ? new TextEncoder().encode(data) : data;
|
||||
const hash = await crypto.subtle.digest('SHA-256', input);
|
||||
return base64urlEncode(new Uint8Array(hash));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ed25519 signature verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Import a raw Ed25519 public key (32 bytes) as a CryptoKey.
|
||||
*
|
||||
* @param publicKeyBytes - 32-byte raw Ed25519 public key
|
||||
*/
|
||||
export async function importEd25519PublicKey(publicKeyBytes: Uint8Array): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
'raw',
|
||||
publicKeyBytes as unknown as Uint8Array<ArrayBuffer>,
|
||||
{ name: 'Ed25519' },
|
||||
true, // extractable
|
||||
['verify'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an Ed25519 signature over data.
|
||||
*
|
||||
* @param publicKeyBytes - 32-byte raw public key
|
||||
* @param signatureBytes - 64-byte Ed25519 signature
|
||||
* @param data - The signed data
|
||||
* @returns true if the signature is valid
|
||||
*/
|
||||
export async function verifyEd25519Signature(
|
||||
publicKeyBytes: Uint8Array,
|
||||
signatureBytes: Uint8Array,
|
||||
data: Uint8Array,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const key = await importEd25519PublicKey(publicKeyBytes);
|
||||
return crypto.subtle.verify(
|
||||
{ name: 'Ed25519' },
|
||||
key,
|
||||
signatureBytes as unknown as Uint8Array<ArrayBuffer>,
|
||||
data as unknown as Uint8Array<ArrayBuffer>,
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ed25519 signing (server-side receipt signing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* PKCS8 DER prefix for wrapping a 32-byte Ed25519 seed into a valid PKCS8 key.
|
||||
*
|
||||
* SEQUENCE {
|
||||
* INTEGER 0
|
||||
* SEQUENCE { OID 1.3.101.112 }
|
||||
* OCTET STRING { OCTET STRING { <32 bytes> } }
|
||||
* }
|
||||
*/
|
||||
const ED25519_PKCS8_PREFIX = new Uint8Array([
|
||||
0x30, 0x2e, // SEQUENCE (46 bytes)
|
||||
0x02, 0x01, 0x00, // INTEGER 0
|
||||
0x30, 0x05, // SEQUENCE (5 bytes)
|
||||
0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112 (Ed25519)
|
||||
0x04, 0x22, // OCTET STRING (34 bytes)
|
||||
0x04, 0x20, // OCTET STRING (32 bytes)
|
||||
]);
|
||||
|
||||
/** Wrap a 32-byte Ed25519 seed in a PKCS8 envelope. */
|
||||
function wrapSeedAsPkcs8(seed: Uint8Array): Uint8Array {
|
||||
const pkcs8 = new Uint8Array(ED25519_PKCS8_PREFIX.length + seed.length);
|
||||
pkcs8.set(ED25519_PKCS8_PREFIX);
|
||||
pkcs8.set(seed, ED25519_PKCS8_PREFIX.length);
|
||||
return pkcs8;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a raw Ed25519 private key for signing.
|
||||
*
|
||||
* Node.js expects PKCS8 format for Ed25519 private keys.
|
||||
* We wrap the 32-byte seed in a minimal PKCS8 envelope.
|
||||
*
|
||||
* @param privateKeyBase64url - base64url-encoded 32-byte Ed25519 private key seed
|
||||
*/
|
||||
export async function importEd25519PrivateKey(privateKeyBase64url: string): Promise<CryptoKey> {
|
||||
const seed = base64urlDecode(privateKeyBase64url);
|
||||
const pkcs8 = wrapSeedAsPkcs8(seed);
|
||||
|
||||
return crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
pkcs8 as unknown as Uint8Array<ArrayBuffer>,
|
||||
{ name: 'Ed25519' },
|
||||
false,
|
||||
['sign'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign data with an Ed25519 private key.
|
||||
*
|
||||
* @param privateKeyBase64url - base64url-encoded 32-byte private key seed
|
||||
* @param data - data to sign
|
||||
* @returns base64url-encoded 64-byte signature
|
||||
*/
|
||||
export async function signEd25519(
|
||||
privateKeyBase64url: string,
|
||||
data: Uint8Array,
|
||||
): Promise<string> {
|
||||
const key = await importEd25519PrivateKey(privateKeyBase64url);
|
||||
const signature = await crypto.subtle.sign(
|
||||
{ name: 'Ed25519' },
|
||||
key,
|
||||
data as unknown as Uint8Array<ArrayBuffer>,
|
||||
);
|
||||
return base64urlEncode(new Uint8Array(signature));
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the public key from an Ed25519 private key (for JWKS).
|
||||
*
|
||||
* @param privateKeyBase64url - base64url-encoded 32-byte private key seed
|
||||
* @returns base64url-encoded 32-byte public key
|
||||
*/
|
||||
export async function deriveEd25519PublicKey(privateKeyBase64url: string): Promise<string> {
|
||||
const seed = base64urlDecode(privateKeyBase64url);
|
||||
const pkcs8 = wrapSeedAsPkcs8(seed);
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
pkcs8 as unknown as Uint8Array<ArrayBuffer>,
|
||||
{ name: 'Ed25519' },
|
||||
true, // extractable — needed to export the public key
|
||||
['sign'],
|
||||
);
|
||||
// Export as JWK to get the public 'x' parameter
|
||||
const jwk = await crypto.subtle.exportKey('jwk', key) as JsonWebKey;
|
||||
// The 'x' field in a JWK for OKP/Ed25519 is the base64url-encoded public key
|
||||
return jwk.x ?? '';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JCS (JSON Canonicalization Scheme - RFC 8785)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Canonicalize a JSON value according to JCS (RFC 8785).
|
||||
*
|
||||
* JCS specifies:
|
||||
* - Object keys sorted lexicographically by UTF-16 code units
|
||||
* - No whitespace
|
||||
* - Numbers serialized using ES2015 Number serialization
|
||||
* - Strings serialized with minimal escaping per JSON spec
|
||||
*/
|
||||
export function jcsCanonicalise(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return 'null';
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean' || typeof value === 'number') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const elements = value.map((v) => jcsCanonicalise(v));
|
||||
return '[' + elements.join(',') + ']';
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj).sort();
|
||||
const pairs: string[] = [];
|
||||
for (const key of keys) {
|
||||
if (obj[key] !== undefined) {
|
||||
pairs.push(JSON.stringify(key) + ':' + jcsCanonicalise(obj[key]));
|
||||
}
|
||||
}
|
||||
return '{' + pairs.join(',') + '}';
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chain hash computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the chain hash for an operation.
|
||||
*
|
||||
* chain_hash = SHA-256(prev_chain_hash + payload_hash + operation_id + issued_at)
|
||||
*
|
||||
* All inputs are concatenated as UTF-8 strings separated by '|'.
|
||||
*/
|
||||
export async function computeChainHash(
|
||||
prevChainHash: string,
|
||||
payloadHash: string,
|
||||
operationId: string,
|
||||
issuedAt: number,
|
||||
): Promise<string> {
|
||||
const input = `${prevChainHash}|${payloadHash}|${operationId}|${issuedAt}`;
|
||||
return sha256Base64url(input);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Receipt hash computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the hash of an EAR receipt for signing.
|
||||
*
|
||||
* Hashes the canonicalized receipt fields (excluding the signature fields themselves).
|
||||
*/
|
||||
export async function computeReceiptHash(receiptFields: {
|
||||
receipt_version: string;
|
||||
receipt_id: string;
|
||||
operation_id: string;
|
||||
org_id: string;
|
||||
agent_id: string;
|
||||
server_received_at: number;
|
||||
seq_no: number;
|
||||
chain_hash: string;
|
||||
queue_message_id: string;
|
||||
}): Promise<string> {
|
||||
const canonical = jcsCanonicalise(receiptFields);
|
||||
return sha256Base64url(canonical);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* SHA-256 Merkle tree implementation for epoch rollups.
|
||||
*
|
||||
* Leaf hashes are sorted lexicographically before tree construction.
|
||||
* Internal nodes are computed by concatenating the raw bytes (not base64url
|
||||
* strings) of their children and hashing the result with SHA-256.
|
||||
*/
|
||||
|
||||
import { sha256Base64url, base64urlDecode } from './crypto.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface MerkleTree {
|
||||
/** Base64url SHA-256 root hash */
|
||||
root: string;
|
||||
/** Sorted leaf hashes */
|
||||
leaves: string[];
|
||||
/** Operation IDs matching sorted leaves */
|
||||
leafOps: string[];
|
||||
/** All tree layers from leaves (index 0) to root (last index) */
|
||||
layers: string[][];
|
||||
}
|
||||
|
||||
export interface MerkleProof {
|
||||
/** The leaf hash being proved */
|
||||
leaf: string;
|
||||
/** Sibling hashes along the path to the root */
|
||||
siblings: string[];
|
||||
/** Direction of each sibling ('left' if sibling is on the left) */
|
||||
directions: ('left' | 'right')[];
|
||||
/** The expected root hash */
|
||||
root: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the parent hash from two child hashes.
|
||||
* Decodes both base64url hashes to raw bytes, concatenates, then SHA-256.
|
||||
*/
|
||||
async function hashPair(left: string, right: string): Promise<string> {
|
||||
const leftBytes = base64urlDecode(left);
|
||||
const rightBytes = base64urlDecode(right);
|
||||
const combined = new Uint8Array(leftBytes.length + rightBytes.length);
|
||||
combined.set(leftBytes);
|
||||
combined.set(rightBytes, leftBytes.length);
|
||||
return sha256Base64url(combined);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a Merkle tree from leaf hashes and their corresponding operation IDs.
|
||||
*
|
||||
* Leaves are sorted lexicographically (with operationIds reordered to match).
|
||||
* If a layer has an odd number of nodes the last node is duplicated.
|
||||
*/
|
||||
export async function buildMerkleTree(
|
||||
leafHashes: string[],
|
||||
operationIds: string[],
|
||||
): Promise<MerkleTree> {
|
||||
// Pair up leaves with their operation IDs and sort by leaf hash
|
||||
const paired = leafHashes.map((hash, i) => ({
|
||||
hash,
|
||||
opId: operationIds[i]!,
|
||||
}));
|
||||
paired.sort((a, b) => (a.hash < b.hash ? -1 : a.hash > b.hash ? 1 : 0));
|
||||
|
||||
const sortedLeaves = paired.map((p) => p.hash);
|
||||
const sortedOps = paired.map((p) => p.opId);
|
||||
|
||||
const layers: string[][] = [sortedLeaves];
|
||||
|
||||
let currentLayer = sortedLeaves;
|
||||
while (currentLayer.length > 1) {
|
||||
const nextLayer: string[] = [];
|
||||
// If odd, duplicate the last node
|
||||
if (currentLayer.length % 2 !== 0) {
|
||||
currentLayer = [...currentLayer, currentLayer[currentLayer.length - 1]!];
|
||||
}
|
||||
for (let i = 0; i < currentLayer.length; i += 2) {
|
||||
nextLayer.push(await hashPair(currentLayer[i]!, currentLayer[i + 1]!));
|
||||
}
|
||||
layers.push(nextLayer);
|
||||
currentLayer = nextLayer;
|
||||
}
|
||||
|
||||
return {
|
||||
root: currentLayer[0]!,
|
||||
leaves: sortedLeaves,
|
||||
leafOps: sortedOps,
|
||||
layers,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proof generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a Merkle inclusion proof for a given leaf hash.
|
||||
*
|
||||
* Returns null if the leaf is not found in the tree.
|
||||
*/
|
||||
export function getMerkleProof(
|
||||
tree: MerkleTree,
|
||||
leafHash: string,
|
||||
): MerkleProof | null {
|
||||
let index = tree.leaves.indexOf(leafHash);
|
||||
if (index === -1) return null;
|
||||
|
||||
const siblings: string[] = [];
|
||||
const directions: ('left' | 'right')[] = [];
|
||||
|
||||
for (let layerIdx = 0; layerIdx < tree.layers.length - 1; layerIdx++) {
|
||||
let layer = tree.layers[layerIdx]!;
|
||||
// Duplicate last node if odd (mirrors build logic)
|
||||
if (layer.length % 2 !== 0) {
|
||||
layer = [...layer, layer[layer.length - 1]!];
|
||||
}
|
||||
const siblingIndex = index % 2 === 0 ? index + 1 : index - 1;
|
||||
siblings.push(layer[siblingIndex]!);
|
||||
directions.push(index % 2 === 0 ? 'right' : 'left');
|
||||
index = Math.floor(index / 2);
|
||||
}
|
||||
|
||||
return {
|
||||
leaf: leafHash,
|
||||
siblings,
|
||||
directions,
|
||||
root: tree.root,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Proof verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Verify a Merkle inclusion proof by recomputing the root.
|
||||
*/
|
||||
export async function verifyMerkleProof(proof: MerkleProof): Promise<boolean> {
|
||||
let current = proof.leaf;
|
||||
|
||||
for (let i = 0; i < proof.siblings.length; i++) {
|
||||
const sibling = proof.siblings[i]!;
|
||||
const direction = proof.directions[i]!;
|
||||
if (direction === 'left') {
|
||||
current = await hashPair(sibling, current);
|
||||
} else {
|
||||
current = await hashPair(current, sibling);
|
||||
}
|
||||
}
|
||||
|
||||
return current === proof.root;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Cursor-based pagination helpers.
|
||||
*
|
||||
* Cursors are opaque base64url-encoded JSON objects containing the
|
||||
* created_at timestamp and primary key of the last item in the current
|
||||
* page. This design avoids OFFSET-based pagination which degrades at
|
||||
* scale and is susceptible to drift when rows are inserted concurrently.
|
||||
*/
|
||||
|
||||
import { base64urlDecode, base64urlEncode } from './crypto.js';
|
||||
|
||||
/** Shape of the decoded cursor payload. */
|
||||
export interface CursorPayload {
|
||||
/** created_at of the last item on the previous page */
|
||||
readonly created_at: number;
|
||||
/** Primary key of the last item on the previous page */
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a cursor payload into an opaque cursor string.
|
||||
*
|
||||
* @param payload - The cursor data to encode
|
||||
* @returns An opaque base64url-encoded cursor string
|
||||
*/
|
||||
export function encodeCursor(payload: CursorPayload): string {
|
||||
const json = JSON.stringify(payload);
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
return base64urlEncode(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an opaque cursor string back into its payload.
|
||||
*
|
||||
* @param cursor - The opaque cursor string
|
||||
* @returns The decoded cursor payload, or null if invalid
|
||||
*/
|
||||
export function decodeCursor(cursor: string): CursorPayload | null {
|
||||
try {
|
||||
const bytes = base64urlDecode(cursor);
|
||||
const json = new TextDecoder().decode(bytes);
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
if (
|
||||
typeof parsed !== 'object' ||
|
||||
parsed === null ||
|
||||
typeof parsed.created_at !== 'number' ||
|
||||
typeof parsed.id !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { created_at: parsed.created_at, id: parsed.id };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* Branded PDF generator for Elydora compliance exports.
|
||||
*
|
||||
* Produces professional multi-page PDF/1.4 documents with:
|
||||
* - Elydora logo and brand header bar
|
||||
* - Structured metadata section
|
||||
* - Formatted operations table with alternating row stripes
|
||||
* - Branded footer with page numbers
|
||||
*
|
||||
* Uses only built-in PDF Type1 fonts (Helvetica, Courier) —
|
||||
* no external dependencies, works entirely in the Node.js runtime.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PDFReportData {
|
||||
export_id: string;
|
||||
org_id: string;
|
||||
generated_at: string;
|
||||
time_range: string;
|
||||
agent_filter: string;
|
||||
type_filter: string;
|
||||
total: number;
|
||||
operations: Array<{
|
||||
operation_id: string;
|
||||
agent_id: string;
|
||||
operation_type: string;
|
||||
seq_no: number;
|
||||
chain_hash: string;
|
||||
payload_hash: string;
|
||||
created_at: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page layout constants (US Letter: 612 x 792 pt)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PW = 612;
|
||||
const PH = 792;
|
||||
const ML = 56; // left margin
|
||||
const MR = 56; // right margin
|
||||
const CW = PW - ML - MR; // 500pt content width
|
||||
|
||||
// Header bar
|
||||
const HDR_H = 56;
|
||||
const HDR_Y = PH - HDR_H; // 736 — bottom edge of header
|
||||
|
||||
// Footer
|
||||
const FTR_LINE = 44; // y of separator line
|
||||
const FTR_TEXT = 28; // y of footer text baseline
|
||||
|
||||
// Content area
|
||||
const CT = HDR_Y - 20; // 716 — content top
|
||||
const CB = FTR_LINE + 12; // 56 — content bottom
|
||||
|
||||
// Operations table
|
||||
const ROW_H = 12; // row height
|
||||
const TBL_HDR = 22; // table header + separator + gap
|
||||
|
||||
// Metadata section (first page only)
|
||||
const META_H = 172;
|
||||
|
||||
// Table column x-positions
|
||||
const COL_SEQ = ML;
|
||||
const COL_TIME = ML + 38;
|
||||
const COL_OPID = ML + 142;
|
||||
const COL_AGENT = ML + 296;
|
||||
const COL_TYPE = ML + 398;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Brand colours (RGB 0‑1 scale)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const INK = '0.071 0.071 0.071'; // #121212
|
||||
const BG = '0.918 0.918 0.898'; // #EAEAE5
|
||||
const DIM = '0.400 0.400 0.400'; // #666666
|
||||
const BORDER = '0.753 0.753 0.729'; // #C0C0BA
|
||||
const HDR_SUB = '0.620 0.620 0.600'; // subtitle on dark header
|
||||
const STRIPE = '0.955 0.955 0.942'; // subtle even-row background
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PDF drawing primitives
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Escape special chars and strip non-ASCII for PDF text strings. */
|
||||
function esc(s: string): string {
|
||||
return s
|
||||
.replace(/[^\x20-\x7E]/g, '?')
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\(/g, '\\(')
|
||||
.replace(/\)/g, '\\)');
|
||||
}
|
||||
|
||||
/** Truncate string with ".." suffix. */
|
||||
function trunc(s: string, max: number): string {
|
||||
return s.length <= max ? s : s.slice(0, max - 2) + '..';
|
||||
}
|
||||
|
||||
/** Filled rectangle. */
|
||||
function fillRect(x: number, y: number, w: number, h: number, rgb: string): string {
|
||||
return `${rgb} rg ${x} ${y} ${w} ${h} re f`;
|
||||
}
|
||||
|
||||
/** Horizontal line. */
|
||||
function hline(x1: number, y: number, x2: number, rgb: string, w = 0.5): string {
|
||||
return `${rgb} RG ${w} w ${x1} ${y} m ${x2} ${y} l S`;
|
||||
}
|
||||
|
||||
/** Text at absolute position. */
|
||||
function txt(s: string, x: number, y: number, font: string, size: number, rgb: string): string {
|
||||
return `BT ${rgb} rg ${font} ${size} Tf 1 0 0 1 ${x} ${y} Tm (${esc(s)}) Tj ET`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the Elydora "E" logo mark.
|
||||
* SVG source: M3 2H21V7H8V10H17V15H8V18H21V23H3V2Z (viewBox 0 0 24 24)
|
||||
* Transform flips Y for PDF coordinate system.
|
||||
*/
|
||||
function drawLogo(x: number, y: number, size: number, rgb: string): string {
|
||||
const sc = size / 24;
|
||||
return [
|
||||
'q',
|
||||
`${sc} 0 0 ${-sc} ${x} ${y + size} cm`,
|
||||
`${rgb} rg`,
|
||||
'3 2 m 21 2 l 21 7 l 8 7 l 8 10 l 17 10 l 17 15 l 8 15 l 8 18 l 21 18 l 21 23 l 3 23 l h f',
|
||||
'Q',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section renderers — each appends drawing instructions to the page array
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Dark header bar with logo and brand text (every page). */
|
||||
function renderHeader(p: string[]): void {
|
||||
// Full-width dark bar
|
||||
p.push(fillRect(0, HDR_Y, PW, HDR_H, INK));
|
||||
// Thin accent line at bottom edge
|
||||
p.push(hline(0, HDR_Y, PW, BG, 1.5));
|
||||
|
||||
// Logo mark
|
||||
const sz = 30;
|
||||
const lx = ML;
|
||||
const ly = HDR_Y + (HDR_H - sz) / 2; // vertically centred
|
||||
p.push(drawLogo(lx, ly, sz, BG));
|
||||
|
||||
// Brand name
|
||||
const tx = lx + sz + 12;
|
||||
p.push(txt('ELYDORA', tx, ly + sz - 16, '/F1', 18, BG));
|
||||
// Subtitle
|
||||
p.push(txt('COMPLIANCE EXPORT REPORT', tx, ly + 2, '/F2', 8, HDR_SUB));
|
||||
}
|
||||
|
||||
/** Footer with confidentiality note and page number (every page). */
|
||||
function renderFooter(p: string[], pageNum: number, totalPages: number, exportId: string): void {
|
||||
p.push(hline(ML, FTR_LINE, PW - MR, BORDER, 0.5));
|
||||
p.push(txt('CONFIDENTIAL', ML, FTR_TEXT, '/F2', 7, DIM));
|
||||
p.push(txt(`Export ${exportId.slice(0, 8)}`, PW / 2 - 28, FTR_TEXT, '/F3', 7, DIM));
|
||||
p.push(txt(`Page ${pageNum} of ${totalPages}`, PW - MR - 56, FTR_TEXT, '/F2', 7, DIM));
|
||||
}
|
||||
|
||||
/** Metadata key-value section (first page only). Returns new y position. */
|
||||
function renderMetadata(p: string[], data: PDFReportData): number {
|
||||
let y = CT;
|
||||
|
||||
// Section title
|
||||
p.push(txt('EXPORT DETAILS', ML, y, '/F1', 10, INK));
|
||||
y -= 6;
|
||||
p.push(hline(ML, y, PW - MR, BORDER, 0.5));
|
||||
y -= 18;
|
||||
|
||||
// Key-value rows
|
||||
const valX = ML + 112;
|
||||
const fields: [string, string][] = [
|
||||
['Export ID', data.export_id],
|
||||
['Organization', data.org_id],
|
||||
['Generated', data.generated_at],
|
||||
['Time Range', data.time_range],
|
||||
['Agent Filter', data.agent_filter],
|
||||
['Type Filter', data.type_filter],
|
||||
['Total Records', String(data.total)],
|
||||
];
|
||||
for (const [label, value] of fields) {
|
||||
p.push(txt(label, ML, y, '/F2', 8.5, DIM));
|
||||
p.push(txt(value, valX, y, '/F3', 8.5, INK));
|
||||
y -= 16;
|
||||
}
|
||||
|
||||
y -= 8;
|
||||
|
||||
// Operations section title
|
||||
p.push(txt('OPERATIONS', ML, y, '/F1', 10, INK));
|
||||
y -= 6;
|
||||
p.push(hline(ML, y, PW - MR, BORDER, 0.5));
|
||||
y -= 4;
|
||||
|
||||
return y;
|
||||
}
|
||||
|
||||
/** Table column headers. Returns y of first data row. */
|
||||
function renderTableHeader(p: string[], y: number): number {
|
||||
const hy = y - 14;
|
||||
p.push(txt('#', COL_SEQ, hy, '/F1', 7.5, DIM));
|
||||
p.push(txt('TIMESTAMP', COL_TIME, hy, '/F1', 7.5, DIM));
|
||||
p.push(txt('OPERATION ID', COL_OPID, hy, '/F1', 7.5, DIM));
|
||||
p.push(txt('AGENT', COL_AGENT, hy, '/F1', 7.5, DIM));
|
||||
p.push(txt('TYPE', COL_TYPE, hy, '/F1', 7.5, DIM));
|
||||
|
||||
const sy = hy - 5;
|
||||
p.push(hline(ML, sy, PW - MR, BORDER, 0.3));
|
||||
|
||||
return sy - 3;
|
||||
}
|
||||
|
||||
/** Single operation data row. */
|
||||
function renderRow(
|
||||
p: string[],
|
||||
y: number,
|
||||
op: PDFReportData['operations'][number],
|
||||
index: number,
|
||||
): void {
|
||||
// Alternating stripe background
|
||||
if (index % 2 === 0) {
|
||||
p.push(fillRect(ML - 4, y - 3, CW + 8, ROW_H, STRIPE));
|
||||
}
|
||||
|
||||
const ts = new Date(op.created_at).toISOString().slice(0, 19).replace('T', ' ');
|
||||
|
||||
p.push(txt(String(op.seq_no).padStart(4), COL_SEQ, y, '/F3', 8, DIM));
|
||||
p.push(txt(ts, COL_TIME, y, '/F3', 8, INK));
|
||||
p.push(txt(trunc(op.operation_id, 30), COL_OPID, y, '/F3', 8, INK));
|
||||
p.push(txt(trunc(op.agent_id, 18), COL_AGENT, y, '/F3', 8, INK));
|
||||
p.push(txt(trunc(op.operation_type, 20), COL_TYPE, y, '/F3', 8, INK));
|
||||
}
|
||||
|
||||
/** End-of-report note (last page, if space allows). */
|
||||
function renderEndNote(p: string[], y: number): void {
|
||||
y -= 20;
|
||||
p.push(hline(ML, y, PW - MR, BORDER, 0.3));
|
||||
y -= 18;
|
||||
p.push(txt('End of Report', ML, y, '/F1', 9, DIM));
|
||||
y -= 14;
|
||||
p.push(txt(
|
||||
'Full cryptographic chain and payload hashes are available in JSON export format.',
|
||||
ML, y, '/F2', 7.5, DIM,
|
||||
));
|
||||
y -= 12;
|
||||
p.push(txt(
|
||||
'Elydora - The Responsibility Layer for AI Agents',
|
||||
ML, y, '/F2', 7.5, DIM,
|
||||
));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildBrandedPDF(data: PDFReportData): Uint8Array {
|
||||
const ops = data.operations;
|
||||
const totalOps = ops.length;
|
||||
|
||||
// Calculate pagination
|
||||
const firstPageArea = CT - META_H - CB; // ~488pt
|
||||
const opsFirstPage = Math.max(0, Math.floor((firstPageArea - TBL_HDR) / ROW_H));
|
||||
const fullPageArea = CT - CB; // ~660pt
|
||||
const opsPerPage = Math.floor((fullPageArea - TBL_HDR) / ROW_H);
|
||||
|
||||
const totalPages = totalOps <= opsFirstPage
|
||||
? 1
|
||||
: 1 + Math.ceil((totalOps - opsFirstPage) / Math.max(1, opsPerPage));
|
||||
|
||||
// Build content streams (one per page)
|
||||
const streams: string[] = [];
|
||||
let opIdx = 0;
|
||||
|
||||
for (let pg = 0; pg < totalPages; pg++) {
|
||||
const p: string[] = [];
|
||||
|
||||
renderHeader(p);
|
||||
|
||||
let y: number;
|
||||
if (pg === 0) {
|
||||
y = renderMetadata(p, data);
|
||||
} else {
|
||||
y = CT;
|
||||
}
|
||||
|
||||
y = renderTableHeader(p, y);
|
||||
|
||||
const maxOps = pg === 0 ? opsFirstPage : opsPerPage;
|
||||
const endIdx = Math.min(opIdx + maxOps, totalOps);
|
||||
|
||||
while (opIdx < endIdx) {
|
||||
renderRow(p, y, ops[opIdx]!, opIdx);
|
||||
y -= ROW_H;
|
||||
opIdx++;
|
||||
}
|
||||
|
||||
// End note on last page if enough space
|
||||
if (pg === totalPages - 1 && y > CB + 70) {
|
||||
renderEndNote(p, y);
|
||||
}
|
||||
|
||||
renderFooter(p, pg + 1, totalPages, data.export_id);
|
||||
streams.push(p.join('\n'));
|
||||
}
|
||||
|
||||
return assemblePDF(streams);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PDF document assembly
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function assemblePDF(pageStreams: string[]): Uint8Array {
|
||||
const objContents: string[] = [];
|
||||
|
||||
// Obj 1: Catalog
|
||||
objContents.push('<< /Type /Catalog /Pages 2 0 R >>');
|
||||
|
||||
// Obj 2: Pages (placeholder — patched after page objects are created)
|
||||
objContents.push('');
|
||||
|
||||
// Obj 3–5: Built-in fonts
|
||||
objContents.push('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>'); // /F1
|
||||
objContents.push('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'); // /F2
|
||||
objContents.push('<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>'); // /F3
|
||||
|
||||
const fontRes = '/F1 3 0 R /F2 4 0 R /F3 5 0 R';
|
||||
|
||||
// Content stream + page object for each page
|
||||
const pageObjNums: number[] = [];
|
||||
|
||||
for (const stream of pageStreams) {
|
||||
const csNum = objContents.length + 1;
|
||||
objContents.push(`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`);
|
||||
|
||||
const pgNum = objContents.length + 1;
|
||||
objContents.push(
|
||||
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${PW} ${PH}] ` +
|
||||
`/Contents ${csNum} 0 R /Resources << /Font << ${fontRes} >> >> >>`,
|
||||
);
|
||||
pageObjNums.push(pgNum);
|
||||
}
|
||||
|
||||
// Patch Pages object
|
||||
const kids = pageObjNums.map((n) => `${n} 0 R`).join(' ');
|
||||
objContents[1] = `<< /Type /Pages /Kids [${kids}] /Count ${pageStreams.length} >>`;
|
||||
|
||||
// Assemble byte stream
|
||||
const header = '%PDF-1.4\n';
|
||||
const parts: string[] = [header];
|
||||
const offsets: number[] = [];
|
||||
let pos = header.length;
|
||||
|
||||
for (let i = 0; i < objContents.length; i++) {
|
||||
offsets.push(pos);
|
||||
const obj = `${i + 1} 0 obj\n${objContents[i]}\nendobj\n`;
|
||||
parts.push(obj);
|
||||
pos += obj.length;
|
||||
}
|
||||
|
||||
// Cross-reference table
|
||||
const xrefPos = pos;
|
||||
const numEntries = objContents.length + 1;
|
||||
parts.push(`xref\n0 ${numEntries}\n`);
|
||||
parts.push('0000000000 65535 f \n');
|
||||
for (const off of offsets) {
|
||||
parts.push(`${String(off).padStart(10, '0')} 00000 n \n`);
|
||||
}
|
||||
|
||||
// Trailer
|
||||
parts.push(
|
||||
`trailer\n<< /Size ${numEntries} /Root 1 0 R >>\nstartxref\n${xrefPos}\n%%EOF\n`,
|
||||
);
|
||||
|
||||
return new TextEncoder().encode(parts.join(''));
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* RFC 3161 Timestamping Authority (TSA) client utilities.
|
||||
*
|
||||
* Constructs a minimal DER-encoded TimeStampReq and submits it to a
|
||||
* public TSA endpoint. No ASN.1 library required — the 59-byte request
|
||||
* is hand-assembled using the same DER-prefix pattern as the PKCS8
|
||||
* wrapper in crypto.ts.
|
||||
*/
|
||||
|
||||
/** Default public TSA endpoint (Sectigo / Comodo). */
|
||||
export const DEFAULT_TSA_URL = 'http://timestamp.sectigo.com';
|
||||
|
||||
/**
|
||||
* Build a minimal RFC 3161 TimeStampReq (DER-encoded, 59 bytes).
|
||||
*
|
||||
* Structure:
|
||||
* SEQUENCE {
|
||||
* INTEGER 1 -- version
|
||||
* SEQUENCE { -- messageImprint
|
||||
* SEQUENCE { -- hashAlgorithm (SHA-256)
|
||||
* OID 2.16.840.1.101.3.4.2.1
|
||||
* NULL
|
||||
* }
|
||||
* OCTET STRING <32 bytes> -- hashedMessage
|
||||
* }
|
||||
* BOOLEAN TRUE -- certReq
|
||||
* }
|
||||
*/
|
||||
export function buildTimeStampReq(sha256Hash: Uint8Array): Uint8Array {
|
||||
if (sha256Hash.length !== 32) {
|
||||
throw new Error(`Expected 32-byte SHA-256 hash, got ${sha256Hash.length}`);
|
||||
}
|
||||
|
||||
// DER prefix (24 bytes) — everything before the 32-byte hash
|
||||
const prefix = new Uint8Array([
|
||||
0x30, 0x39, // SEQUENCE (57 bytes)
|
||||
0x02, 0x01, 0x01, // INTEGER 1 (version v1)
|
||||
0x30, 0x31, // SEQUENCE (49 bytes) — MessageImprint
|
||||
0x30, 0x0d, // SEQUENCE (13 bytes) — AlgorithmIdentifier
|
||||
0x06, 0x09, // OID (9 bytes)
|
||||
0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, // SHA-256 (2.16.840.1.101.3.4.2.1)
|
||||
0x05, 0x00, // NULL (parameters)
|
||||
0x04, 0x20, // OCTET STRING (32 bytes)
|
||||
]);
|
||||
|
||||
// DER suffix (3 bytes) — certReq = TRUE
|
||||
const suffix = new Uint8Array([0x01, 0x01, 0xff]);
|
||||
|
||||
// Assemble: prefix + hash + suffix = 24 + 32 + 3 = 59 bytes
|
||||
const req = new Uint8Array(prefix.length + sha256Hash.length + suffix.length);
|
||||
req.set(prefix);
|
||||
req.set(sha256Hash, prefix.length);
|
||||
req.set(suffix, prefix.length + sha256Hash.length);
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a SHA-256 hash to a TSA and return the raw DER TimeStampResp.
|
||||
*
|
||||
* @param sha256Hash - 32-byte SHA-256 digest to timestamp
|
||||
* @param tsaUrl - TSA endpoint (defaults to Sectigo)
|
||||
* @returns Raw DER-encoded TimeStampResp bytes
|
||||
*/
|
||||
export async function requestTimestamp(
|
||||
sha256Hash: Uint8Array,
|
||||
tsaUrl: string = DEFAULT_TSA_URL,
|
||||
): Promise<Uint8Array> {
|
||||
const body = buildTimeStampReq(sha256Hash);
|
||||
|
||||
const response = await fetch(tsaUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/timestamp-query',
|
||||
},
|
||||
body: body as unknown as BodyInit,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`TSA request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('Content-Type') ?? '';
|
||||
if (!contentType.includes('application/timestamp-reply')) {
|
||||
throw new Error(`Unexpected TSA response content-type: ${contentType}`);
|
||||
}
|
||||
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* UUIDv7 generator conforming to RFC 9562.
|
||||
*
|
||||
* UUIDv7 embeds a Unix-millisecond timestamp in the most-significant 48 bits,
|
||||
* making identifiers lexicographically sortable and eliminating the need for
|
||||
* a separate created_at column in many cases.
|
||||
*
|
||||
* This implementation uses the Web Crypto API (crypto.getRandomValues) which
|
||||
* is available natively in Node.js 20+.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate a UUIDv7 string.
|
||||
*
|
||||
* Layout (128 bits total):
|
||||
* bits 0-47 : Unix timestamp in milliseconds
|
||||
* bits 48-51 : version (0b0111 = 7)
|
||||
* bits 52-63 : random_a (12 bits)
|
||||
* bits 64-65 : variant (0b10)
|
||||
* bits 66-127: random_b (62 bits)
|
||||
*/
|
||||
export function generateUUIDv7(): string {
|
||||
const now = Date.now();
|
||||
const bytes = new Uint8Array(16);
|
||||
|
||||
// Fill with random bytes first
|
||||
crypto.getRandomValues(bytes);
|
||||
|
||||
// Encode 48-bit timestamp (big-endian) into bytes 0..5
|
||||
const high32 = Math.floor(now / 0x10000); // upper 32 bits of the 48-bit ts
|
||||
const low16 = now & 0xffff; // lower 16 bits of the 48-bit ts
|
||||
|
||||
bytes[0] = (high32 >>> 24) & 0xff;
|
||||
bytes[1] = (high32 >>> 16) & 0xff;
|
||||
bytes[2] = (high32 >>> 8) & 0xff;
|
||||
bytes[3] = high32 & 0xff;
|
||||
bytes[4] = (low16 >>> 8) & 0xff;
|
||||
bytes[5] = low16 & 0xff;
|
||||
|
||||
// Set version 7 (bits 48-51)
|
||||
bytes[6] = (bytes[6]! & 0x0f) | 0x70;
|
||||
|
||||
// Set variant 10xx (bits 64-65)
|
||||
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
||||
|
||||
return formatUUID(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a 16-byte array as a standard UUID string (8-4-4-4-12).
|
||||
*/
|
||||
function formatUUID(bytes: Uint8Array): string {
|
||||
const hex = Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
return (
|
||||
hex.slice(0, 8) +
|
||||
'-' +
|
||||
hex.slice(8, 12) +
|
||||
'-' +
|
||||
hex.slice(12, 16) +
|
||||
'-' +
|
||||
hex.slice(16, 20) +
|
||||
'-' +
|
||||
hex.slice(20, 32)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* BullMQ queue worker — processes operation, export, and TSA jobs.
|
||||
*
|
||||
* Run as a separate Node.js process: node dist/worker.js
|
||||
*/
|
||||
|
||||
import 'dotenv/config';
|
||||
import { Worker, type Job } from 'bullmq';
|
||||
import { QUEUE_NAME } from './adapters/bullmq.js';
|
||||
import { PostgresAdapter } from './adapters/postgres.js';
|
||||
import { MinioAdapter } from './adapters/minio.js';
|
||||
import { RedisCacheAdapter } from './adapters/redis-cache.js';
|
||||
import type { Env } from './types.js';
|
||||
import type { Operation } from './shared/index.js';
|
||||
import { buildBrandedPDF } from './utils/pdf.js';
|
||||
import { requestTimestamp, DEFAULT_TSA_URL } from './utils/tsa.js';
|
||||
import { base64urlDecode, base64urlEncode } from './utils/crypto.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialize adapters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const db = new PostgresAdapter(process.env.DATABASE_URL!);
|
||||
const evidence = new MinioAdapter(
|
||||
process.env.MINIO_ENDPOINT!,
|
||||
process.env.MINIO_ACCESS_KEY!,
|
||||
process.env.MINIO_SECRET_KEY!,
|
||||
process.env.MINIO_BUCKET ?? 'elydora-evidence',
|
||||
);
|
||||
const cache = new RedisCacheAdapter(process.env.REDIS_URL!);
|
||||
|
||||
// Build env object for service compatibility
|
||||
const env: Pick<Env, 'ELYDORA_DB' | 'ELYDORA_EVIDENCE' | 'ELYDORA_CACHE' | 'TSA_URL'> = {
|
||||
ELYDORA_DB: db,
|
||||
ELYDORA_EVIDENCE: evidence,
|
||||
ELYDORA_CACHE: cache,
|
||||
TSA_URL: process.env.TSA_URL,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Message type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface QueueMessageBody {
|
||||
type: 'operation' | 'export' | 'tsa';
|
||||
operation_id?: string;
|
||||
org_id?: string;
|
||||
agent_id?: string;
|
||||
export_id?: string;
|
||||
query_params?: string;
|
||||
epoch_id?: string;
|
||||
root_hash?: string;
|
||||
r2_epoch_key?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Job processor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processJob(job: Job): Promise<void> {
|
||||
const body = job.data as QueueMessageBody;
|
||||
|
||||
switch (body.type) {
|
||||
case 'operation':
|
||||
await processOperation(body);
|
||||
break;
|
||||
case 'export':
|
||||
await processExport(body);
|
||||
break;
|
||||
case 'tsa':
|
||||
await processTSA(body);
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown queue message type: ${String((body as unknown as Record<string, unknown>).type)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operation post-processing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processOperation(body: QueueMessageBody): Promise<void> {
|
||||
if (!body.operation_id || !body.org_id) {
|
||||
throw new Error('Operation queue message missing required fields.');
|
||||
}
|
||||
|
||||
const operation = await env.ELYDORA_DB
|
||||
.prepare('SELECT * FROM operations WHERE operation_id = ?')
|
||||
.bind(body.operation_id)
|
||||
.first<Operation>();
|
||||
|
||||
if (!operation) {
|
||||
throw new Error(`Operation ${body.operation_id} not found in database.`);
|
||||
}
|
||||
|
||||
const cacheKey = `chain:${operation.agent_id}:latest`;
|
||||
await env.ELYDORA_CACHE.put(
|
||||
cacheKey,
|
||||
JSON.stringify({
|
||||
chain_hash: operation.chain_hash,
|
||||
seq_no: operation.seq_no,
|
||||
}),
|
||||
{ expirationTtl: 86400 },
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export job processing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processExport(body: QueueMessageBody): Promise<void> {
|
||||
if (!body.export_id || !body.org_id || !body.query_params) {
|
||||
throw new Error('Export queue message missing required fields.');
|
||||
}
|
||||
|
||||
const { export_id, org_id, query_params } = body;
|
||||
|
||||
await env.ELYDORA_DB
|
||||
.prepare('UPDATE exports SET status = ? WHERE export_id = ?')
|
||||
.bind('running', export_id)
|
||||
.run();
|
||||
|
||||
try {
|
||||
const params = JSON.parse(query_params) as {
|
||||
start_time: number;
|
||||
end_time: number;
|
||||
agent_id: string | null;
|
||||
operation_type: string | null;
|
||||
format: 'json' | 'pdf';
|
||||
};
|
||||
|
||||
const conditions: string[] = ['org_id = ?', 'created_at >= ?', 'created_at <= ?'];
|
||||
const bindings: (string | number)[] = [org_id, params.start_time, params.end_time];
|
||||
|
||||
if (params.agent_id) {
|
||||
conditions.push('agent_id = ?');
|
||||
bindings.push(params.agent_id);
|
||||
}
|
||||
|
||||
if (params.operation_type) {
|
||||
conditions.push('operation_type = ?');
|
||||
bindings.push(params.operation_type);
|
||||
}
|
||||
|
||||
const whereClause = 'WHERE ' + conditions.join(' AND ');
|
||||
const query = `SELECT * FROM operations ${whereClause} ORDER BY created_at ASC`;
|
||||
|
||||
const result = await env.ELYDORA_DB
|
||||
.prepare(query)
|
||||
.bind(...bindings)
|
||||
.all<Operation>();
|
||||
|
||||
const operations = result.results ?? [];
|
||||
|
||||
let exportData: string | Uint8Array;
|
||||
let contentType: string;
|
||||
|
||||
if (params.format === 'json') {
|
||||
exportData = JSON.stringify(
|
||||
{
|
||||
export_id,
|
||||
org_id,
|
||||
generated_at: Date.now(),
|
||||
query: {
|
||||
start_time: params.start_time,
|
||||
end_time: params.end_time,
|
||||
agent_id: params.agent_id,
|
||||
operation_type: params.operation_type,
|
||||
},
|
||||
total_operations: operations.length,
|
||||
operations,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
contentType = 'application/json';
|
||||
} else {
|
||||
const fmtDate = (ms: number) =>
|
||||
new Date(ms).toISOString().slice(0, 16).replace('T', ' ');
|
||||
|
||||
exportData = buildBrandedPDF({
|
||||
export_id,
|
||||
org_id,
|
||||
generated_at: new Date().toISOString(),
|
||||
time_range: `${fmtDate(params.start_time)} to ${fmtDate(params.end_time)}`,
|
||||
agent_filter: params.agent_id ?? 'All agents',
|
||||
type_filter: params.operation_type ?? 'All types',
|
||||
total: operations.length,
|
||||
operations,
|
||||
});
|
||||
contentType = 'application/pdf';
|
||||
}
|
||||
|
||||
const r2Key = `exports/${org_id}/${export_id}`;
|
||||
await env.ELYDORA_EVIDENCE.put(r2Key, exportData, {
|
||||
httpMetadata: { contentType },
|
||||
customMetadata: { export_id, org_id },
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
await env.ELYDORA_DB
|
||||
.prepare(
|
||||
'UPDATE exports SET status = ?, r2_export_key = ?, completed_at = ? WHERE export_id = ?',
|
||||
)
|
||||
.bind('done', r2Key, now, export_id)
|
||||
.run();
|
||||
} catch (error) {
|
||||
const now = Date.now();
|
||||
await env.ELYDORA_DB
|
||||
.prepare('UPDATE exports SET status = ?, completed_at = ? WHERE export_id = ?')
|
||||
.bind('failed', now, export_id)
|
||||
.run();
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TSA anchoring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function processTSA(body: QueueMessageBody): Promise<void> {
|
||||
if (!body.root_hash || !body.r2_epoch_key) {
|
||||
throw new Error('TSA queue message missing required fields.');
|
||||
}
|
||||
|
||||
const { root_hash, r2_epoch_key } = body;
|
||||
const tsaUrl = env.TSA_URL ?? DEFAULT_TSA_URL;
|
||||
|
||||
const hashBytes = base64urlDecode(root_hash);
|
||||
const tsaResponse = await requestTimestamp(hashBytes, tsaUrl);
|
||||
|
||||
const anchorData = {
|
||||
tsa_token: base64urlEncode(tsaResponse),
|
||||
tsa_url: tsaUrl,
|
||||
anchored_at: Date.now(),
|
||||
};
|
||||
|
||||
await env.ELYDORA_EVIDENCE.put(
|
||||
`${r2_epoch_key}/tsa-anchor`,
|
||||
JSON.stringify(anchorData),
|
||||
{ httpMetadata: { contentType: 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Start the BullMQ worker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const redisUrl = new URL(process.env.REDIS_URL!);
|
||||
const connection = {
|
||||
host: redisUrl.hostname,
|
||||
port: Number(redisUrl.port) || 6379,
|
||||
};
|
||||
|
||||
await cache.connect();
|
||||
|
||||
const worker = new Worker(QUEUE_NAME, processJob, {
|
||||
connection,
|
||||
concurrency: 5,
|
||||
});
|
||||
|
||||
console.log(`Elydora queue worker started (queue: ${QUEUE_NAME})`);
|
||||
|
||||
worker.on('completed', (job) => {
|
||||
console.log(`Job ${job.id} completed`);
|
||||
});
|
||||
|
||||
worker.on('failed', (job, err) => {
|
||||
console.error(`Job ${job?.id} failed:`, err.message);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
async function shutdown() {
|
||||
console.log('Shutting down worker...');
|
||||
await worker.close();
|
||||
await cache.close();
|
||||
await db.close();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in new issue
Block a user