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:
ZacharyZhang-NYandClaude Opus 4.6 committed 2026-03-02 13:41:42 -05:00
1 parent 15547a8c35
commit e390e68594
207 files changed
+32790 -63

No files matched your search

@@ -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');
}
}
}