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