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,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 };
|
||||
Reference in new issue
Block a user