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

+36
View File
@@ -0,0 +1,36 @@
/**
* BullMQ queue adapter implementing the MessageQueue interface.
*
* Wraps a BullMQ Queue instance to replace Cloudflare's Queue producer.
* Jobs are enqueued with retry-on-failure semantics (3 attempts,
* exponential back-off) matching the Cloudflare Queues default behaviour.
*/
import { Queue as BullQueue } from 'bullmq';
import type { ConnectionOptions } from 'bullmq';
import type { MessageQueue } from './interfaces.js';
import { generateUUIDv7 } from '../utils/uuid.js';
export const QUEUE_NAME = 'elydora-queue';
export class BullMQAdapter implements MessageQueue {
private readonly queue: BullQueue;
constructor(connection: ConnectionOptions) {
this.queue = new BullQueue(QUEUE_NAME, { connection });
}
async send(body: unknown): Promise<{ messageId: string }> {
const job = await this.queue.add('message', body as Record<string, unknown>, {
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: { count: 100 },
removeOnFail: { count: 500 },
});
return { messageId: job.id ?? generateUUIDv7() };
}
async close(): Promise<void> {
await this.queue.close();
}
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Adapter interfaces for ElydoraOpenSource infrastructure services.
*
* These interfaces mirror the shape of Cloudflare's D1, R2, KV, and Queue
* APIs so that all existing service code can run unchanged against
* PostgreSQL, MinIO, Redis, and BullMQ implementations.
*/
// ---------------------------------------------------------------------------
// Database (replaces D1Database)
// ---------------------------------------------------------------------------
/**
* A prepared (and optionally bound) SQL statement.
* Mirrors D1PreparedStatement's interface exactly.
*/
export interface PreparedStatement {
/**
* Bind positional parameters (? placeholders) to the statement.
* Returns a new statement with the values set.
*/
bind(...values: unknown[]): PreparedStatement;
/** Execute and return the first row, or null if no rows match. */
first<T = unknown>(): Promise<T | null>;
/** Execute and return all matching rows. */
all<T = unknown>(): Promise<{ results: T[] }>;
/** Execute a write statement (INSERT/UPDATE/DELETE). */
run(): Promise<{ success: boolean }>;
}
/**
* Relational database adapter.
* Mirrors the subset of D1Database methods used by Elydora services.
*/
export interface Database {
/** Prepare a SQL statement for execution. */
prepare(sql: string): PreparedStatement;
/**
* Execute multiple prepared statements atomically (within a transaction).
* Mirrors D1Database.batch().
*/
batch(statements: PreparedStatement[]): Promise<{ results: unknown[]; success: boolean }[]>;
}
// ---------------------------------------------------------------------------
// Object store (replaces R2Bucket)
// ---------------------------------------------------------------------------
export interface ObjectStorePutOptions {
httpMetadata?: { contentType?: string };
customMetadata?: Record<string, string>;
}
export interface ObjectStoreObject {
/** Readable body stream of the stored object. */
readonly body: ReadableStream;
/** Object size in bytes, if known. */
readonly size?: number;
/** HTTP metadata associated with the object. */
readonly httpMetadata?: { readonly contentType?: string };
/** Parse the body as JSON. */
json<T = unknown>(): Promise<T>;
/** Return the body as a UTF-8 string. */
text(): Promise<string>;
}
export interface ObjectStoreHead {
readonly size?: number;
readonly httpMetadata?: { readonly contentType?: string };
}
/**
* Object/blob storage adapter.
* Mirrors the subset of R2Bucket methods used by Elydora services.
*/
export interface ObjectStore {
/** Upload an object to the store. */
put(
key: string,
body: string | Uint8Array | ReadableStream,
options?: ObjectStorePutOptions,
): Promise<void>;
/** Retrieve an object, or null if not found. */
get(key: string): Promise<ObjectStoreObject | null>;
/** Check if an object exists (metadata only). Returns null if not found. */
head(key: string): Promise<ObjectStoreHead | null>;
}
// ---------------------------------------------------------------------------
// Cache (replaces KVNamespace)
// ---------------------------------------------------------------------------
/**
* Key/value cache adapter.
* Mirrors the subset of KVNamespace methods used by Elydora services.
*/
export interface Cache {
/** Retrieve a cached value, or null if not found / expired. */
get(key: string): Promise<string | null>;
/** Store a value in the cache with an optional TTL in seconds. */
put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;
}
// ---------------------------------------------------------------------------
// Message queue (replaces Queue)
// ---------------------------------------------------------------------------
/**
* Message queue adapter.
* Mirrors the subset of Cloudflare Queue methods used by Elydora services.
*/
export interface MessageQueue {
/** Enqueue a message. Returns the assigned message ID. */
send(body: unknown): Promise<{ messageId: string }>;
}
+151
View File
@@ -0,0 +1,151 @@
/**
* MinIO / S3-compatible object storage adapter implementing the ObjectStore interface.
*
* Uses the AWS SDK v3 S3 client configured with a custom endpoint for MinIO.
* Responses are buffered in memory so that both streaming (body) and random
* access (json/text) are available on the returned ObjectStoreObject.
*/
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
HeadObjectCommand,
type PutObjectCommandInput,
} from '@aws-sdk/client-s3';
import { Readable } from 'stream';
import type {
ObjectStore,
ObjectStoreObject,
ObjectStoreHead,
ObjectStorePutOptions,
} from './interfaces.js';
// ---------------------------------------------------------------------------
// S3 error detection
// ---------------------------------------------------------------------------
function isNoSuchKey(err: unknown): boolean {
if (!err || typeof err !== 'object') return false;
const code = (err as Record<string, unknown>).Code ?? (err as Record<string, unknown>).name;
return code === 'NoSuchKey' || code === 'NotFound';
}
// ---------------------------------------------------------------------------
// MinIO adapter
// ---------------------------------------------------------------------------
export class MinioAdapter implements ObjectStore {
private readonly client: S3Client;
private readonly bucket: string;
constructor(endpoint: string, accessKey: string, secretKey: string, bucket: string) {
this.client = new S3Client({
endpoint,
region: 'us-east-1', // required by S3 SDK, MinIO ignores it
credentials: {
accessKeyId: accessKey,
secretAccessKey: secretKey,
},
forcePathStyle: true, // required for MinIO
});
this.bucket = bucket;
}
async put(
key: string,
body: string | Uint8Array | ReadableStream,
options?: ObjectStorePutOptions,
): Promise<void> {
let bodyData: Buffer | string | Uint8Array;
if (typeof body === 'string') {
bodyData = body;
} else if (body instanceof Uint8Array) {
bodyData = body;
} else {
// Web ReadableStream — collect into buffer
const chunks: Uint8Array[] = [];
const reader = body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) chunks.push(value);
}
bodyData = Buffer.concat(chunks);
}
const params: PutObjectCommandInput = {
Bucket: this.bucket,
Key: key,
Body: bodyData,
};
if (options?.httpMetadata?.contentType) {
params.ContentType = options.httpMetadata.contentType;
}
if (options?.customMetadata) {
params.Metadata = options.customMetadata;
}
await this.client.send(new PutObjectCommand(params));
}
async get(key: string): Promise<ObjectStoreObject | null> {
try {
const response = await this.client.send(
new GetObjectCommand({ Bucket: this.bucket, Key: key }),
);
if (!response.Body) return null;
// Buffer the entire response so that json(), text(), and body can all work
const nodeReadable = response.Body as Readable;
const chunks: Buffer[] = [];
for await (const chunk of nodeReadable) {
chunks.push(chunk as Buffer);
}
const buffer = Buffer.concat(chunks);
const contentType = response.ContentType;
const contentLength = response.ContentLength;
return {
get body(): ReadableStream {
return new ReadableStream({
start(controller) {
controller.enqueue(buffer);
controller.close();
},
});
},
size: contentLength,
httpMetadata: contentType ? { contentType } : undefined,
async json<T = unknown>(): Promise<T> {
return JSON.parse(buffer.toString('utf-8')) as T;
},
async text(): Promise<string> {
return buffer.toString('utf-8');
},
};
} catch (err) {
if (isNoSuchKey(err)) return null;
throw err;
}
}
async head(key: string): Promise<ObjectStoreHead | null> {
try {
const response = await this.client.send(
new HeadObjectCommand({ Bucket: this.bucket, Key: key }),
);
return {
size: response.ContentLength,
httpMetadata: response.ContentType ? { contentType: response.ContentType } : undefined,
};
} catch (err) {
if (isNoSuchKey(err)) return null;
throw err;
}
}
}
+130
View File
@@ -0,0 +1,130 @@
/**
* PostgreSQL adapter implementing the Database interface.
*
* Translates the D1-style API (prepare/bind/first/all/run/batch) into
* standard `pg` Pool queries. SQLite `?` placeholders are converted to
* PostgreSQL `$N` positional parameters automatically.
*
* Batch execution runs all statements inside a single transaction to
* preserve the atomicity guarantee of D1.batch().
*/
import pg from 'pg';
import type { Database, PreparedStatement } from './interfaces.js';
const { Pool, types } = pg;
type Pool = InstanceType<typeof pg.Pool>;
type PoolClient = pg.PoolClient;
// Parse PostgreSQL BIGINT (oid 20) as JavaScript number instead of string.
// Elydora timestamps are Unix milliseconds (~1.7e12) which fit safely within
// Number.MAX_SAFE_INTEGER (~9e15).
types.setTypeParser(20, (val: string) => Number(val));
// ---------------------------------------------------------------------------
// Placeholder conversion
// ---------------------------------------------------------------------------
/**
* Convert SQLite `?` positional placeholders to PostgreSQL `$N` style.
* Only replaces `?` outside of SQL string literals (safe for all Elydora queries).
*/
function convertPlaceholders(sql: string): string {
let index = 0;
return sql.replace(/\?/g, () => `$${++index}`);
}
// ---------------------------------------------------------------------------
// Statement implementation
// ---------------------------------------------------------------------------
/**
* Internal PostgreSQL statement that implements PreparedStatement.
* Stores the SQL and bound values; executes lazily on first/all/run/executeWith.
*/
class PostgresStatement implements PreparedStatement {
constructor(
private readonly pool: Pool,
private readonly sql: string,
private readonly values: unknown[],
) {}
bind(...values: unknown[]): PreparedStatement {
return new PostgresStatement(this.pool, this.sql, values);
}
async first<T = unknown>(): Promise<T | null> {
const result = await this.pool.query<Record<string, unknown>>({
text: convertPlaceholders(this.sql),
values: this.values as unknown[],
});
return (result.rows[0] as T) ?? null;
}
async all<T = unknown>(): Promise<{ results: T[] }> {
const result = await this.pool.query<Record<string, unknown>>({
text: convertPlaceholders(this.sql),
values: this.values as unknown[],
});
return { results: result.rows as T[] };
}
async run(): Promise<{ success: boolean }> {
await this.pool.query({
text: convertPlaceholders(this.sql),
values: this.values as unknown[],
});
return { success: true };
}
/** Execute using a specific pooled client (used by batch transaction). */
async executeWith(client: PoolClient): Promise<{ results: unknown[]; success: boolean }> {
const result = await client.query({
text: convertPlaceholders(this.sql),
values: this.values as unknown[],
});
return { results: result.rows, success: true };
}
}
// ---------------------------------------------------------------------------
// PostgreSQL adapter
// ---------------------------------------------------------------------------
export class PostgresAdapter implements Database {
private readonly pool: Pool;
constructor(connectionString: string) {
this.pool = new Pool({ connectionString });
}
prepare(sql: string): PreparedStatement {
return new PostgresStatement(this.pool, sql, []);
}
async batch(
statements: PreparedStatement[],
): Promise<{ results: unknown[]; success: boolean }[]> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
const results: { results: unknown[]; success: boolean }[] = [];
for (const stmt of statements) {
const pgStmt = stmt as PostgresStatement;
results.push(await pgStmt.executeWith(client));
}
await client.query('COMMIT');
return results;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
/** Close the connection pool (call on graceful shutdown). */
async close(): Promise<void> {
await this.pool.end();
}
}
@@ -0,0 +1,41 @@
/**
* Redis cache adapter implementing the Cache interface.
*
* Uses ioredis to back the nonce-deduplication and chain-hash caching
* that was previously served by Cloudflare KVNamespace.
*/
import { Redis } from 'ioredis';
import type { Cache } from './interfaces.js';
export class RedisCacheAdapter implements Cache {
private readonly client: Redis;
constructor(redisUrl: string) {
this.client = new Redis(redisUrl, { lazyConnect: true });
}
async get(key: string): Promise<string | null> {
return this.client.get(key);
}
async put(
key: string,
value: string,
options?: { expirationTtl?: number },
): Promise<void> {
if (options?.expirationTtl) {
await this.client.set(key, value, 'EX', options.expirationTtl);
} else {
await this.client.set(key, value);
}
}
async connect(): Promise<void> {
await this.client.connect();
}
async close(): Promise<void> {
await this.client.quit();
}
}
+427
View File
@@ -0,0 +1,427 @@
/**
* Internationalization (i18n) translation map.
*
* Provides bilingual (English / Chinese) translations for all user-facing
* error and validation messages returned by the Elydora API.
*
* Message keys follow a dot-separated convention:
* <domain>.<specificMessage>
*
* Some messages accept parameters via simple {param} placeholders that
* callers must replace before returning the message to the client.
*/
export type Lang = 'en' | 'zh';
const messages: Record<string, Record<Lang, string>> = {
// ---------------------------------------------------------------------------
// Error code default messages (src/shared/constants/errors.ts)
// ---------------------------------------------------------------------------
'error.invalidSignature': {
en: 'The operation signature is invalid.',
zh: '\u64cd\u4f5c\u7b7e\u540d\u65e0\u6548\u3002',
},
'error.unknownAgent': {
en: 'The specified agent does not exist.',
zh: '\u6307\u5b9a\u7684\u4ee3\u7406\u4e0d\u5b58\u5728\u3002',
},
'error.keyRevoked': {
en: 'The signing key has been revoked.',
zh: '\u7b7e\u540d\u5bc6\u94a5\u5df2\u88ab\u64a4\u9500\u3002',
},
'error.agentFrozen': {
en: 'The agent is frozen and cannot submit operations.',
zh: '\u8be5\u4ee3\u7406\u5df2\u88ab\u51bb\u7ed3\uff0c\u65e0\u6cd5\u63d0\u4ea4\u64cd\u4f5c\u3002',
},
'error.ttlExpired': {
en: 'The operation TTL has expired.',
zh: '\u64cd\u4f5c\u7684 TTL \u5df2\u8fc7\u671f\u3002',
},
'error.replayDetected': {
en: 'A duplicate nonce was detected (replay attack).',
zh: '\u68c0\u6d4b\u5230\u91cd\u590d\u7684\u968f\u673a\u6570\uff08\u91cd\u653e\u653b\u51fb\uff09\u3002',
},
'error.prevHashMismatch': {
en: 'The previous chain hash does not match.',
zh: '\u524d\u4e00\u4e2a\u94fe\u54c8\u5e0c\u4e0d\u5339\u914d\u3002',
},
'error.payloadTooLarge': {
en: 'The operation payload exceeds the maximum allowed size.',
zh: '\u64cd\u4f5c\u8d1f\u8f7d\u8d85\u8fc7\u4e86\u5141\u8bb8\u7684\u6700\u5927\u5927\u5c0f\u3002',
},
'error.rateLimited': {
en: 'Too many requests. Please retry later.',
zh: '\u8bf7\u6c42\u8fc7\u591a\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002',
},
'error.internalError': {
en: 'An internal server error occurred.',
zh: '\u53d1\u751f\u4e86\u5185\u90e8\u670d\u52a1\u5668\u9519\u8bef\u3002',
},
'error.unauthorized': {
en: 'Authentication is required.',
zh: '\u9700\u8981\u8eab\u4efd\u9a8c\u8bc1\u3002',
},
'error.forbidden': {
en: 'You do not have permission to perform this action.',
zh: '\u60a8\u6ca1\u6709\u6267\u884c\u6b64\u64cd\u4f5c\u7684\u6743\u9650\u3002',
},
'error.notFound': {
en: 'The requested resource was not found.',
zh: '\u672a\u627e\u5230\u8bf7\u6c42\u7684\u8d44\u6e90\u3002',
},
'error.validationError': {
en: 'The request failed validation.',
zh: '\u8bf7\u6c42\u672a\u901a\u8fc7\u9a8c\u8bc1\u3002',
},
// ---------------------------------------------------------------------------
// Auth middleware (src/middleware/auth.ts)
// ---------------------------------------------------------------------------
'auth.missingHeader': {
en: 'Missing Authorization header',
zh: '\u7f3a\u5c11 Authorization \u8bf7\u6c42\u5934',
},
'auth.bearerRequired': {
en: 'Authorization header must use Bearer scheme',
zh: 'Authorization \u8bf7\u6c42\u5934\u5fc5\u987b\u4f7f\u7528 Bearer \u65b9\u6848',
},
'auth.emptyToken': {
en: 'Empty Bearer token',
zh: 'Bearer \u4ee4\u724c\u4e3a\u7a7a',
},
'auth.malformedJwt': {
en: 'Malformed JWT: expected 3 parts',
zh: 'JWT \u683c\u5f0f\u9519\u8bef\uff1a\u5e94\u5305\u542b 3 \u4e2a\u90e8\u5206',
},
'auth.invalidSignature': {
en: 'Invalid JWT signature',
zh: 'JWT \u7b7e\u540d\u65e0\u6548',
},
'auth.malformedPayload': {
en: 'Malformed JWT payload',
zh: 'JWT \u8d1f\u8f7d\u683c\u5f0f\u9519\u8bef',
},
'auth.missingSub': {
en: 'JWT missing required "sub" claim',
zh: 'JWT \u7f3a\u5c11\u5fc5\u9700\u7684 "sub" \u58f0\u660e',
},
'auth.missingOrgId': {
en: 'JWT missing required "org_id" claim',
zh: 'JWT \u7f3a\u5c11\u5fc5\u9700\u7684 "org_id" \u58f0\u660e',
},
'auth.missingRole': {
en: 'JWT missing required "role" claim',
zh: 'JWT \u7f3a\u5c11\u5fc5\u9700\u7684 "role" \u58f0\u660e',
},
'auth.missingExp': {
en: 'JWT missing required "exp" claim',
zh: 'JWT \u7f3a\u5c11\u5fc5\u9700\u7684 "exp" \u58f0\u660e',
},
'auth.expired': {
en: 'JWT has expired',
zh: 'JWT \u5df2\u8fc7\u671f',
},
'auth.unsupportedAlgorithm': {
en: 'Unsupported JWT algorithm. Only HS256 is accepted.',
zh: '\u4e0d\u652f\u6301\u7684 JWT \u7b97\u6cd5\u3002\u4ec5\u63a5\u53d7 HS256\u3002',
},
'auth.ttlMustBePositive': {
en: 'ttl_seconds must be a positive number',
zh: 'ttl_seconds \u5fc5\u987b\u4e3a\u6b63\u6570',
},
// ---------------------------------------------------------------------------
// RBAC middleware (src/middleware/rbac.ts)
// ---------------------------------------------------------------------------
'rbac.noRole': {
en: 'No role found in request context',
zh: '\u8bf7\u6c42\u4e0a\u4e0b\u6587\u4e2d\u672a\u627e\u5230\u89d2\u8272',
},
'rbac.insufficientPermissions': {
en: 'Insufficient permissions for this action.',
zh: '\u6743\u9650\u4e0d\u8db3\uff0c\u65e0\u6cd5\u6267\u884c\u6b64\u64cd\u4f5c\u3002',
},
// ---------------------------------------------------------------------------
// 404 handler (src/index.ts)
// ---------------------------------------------------------------------------
'notFound.resource': {
en: 'The requested resource was not found.',
zh: '\u672a\u627e\u5230\u8bf7\u6c42\u7684\u8d44\u6e90\u3002',
},
// ---------------------------------------------------------------------------
// Auth routes (src/routes/auth.ts)
// ---------------------------------------------------------------------------
'auth.ttlMustBeNumber': {
en: 'ttl_seconds must be a number or null',
zh: 'ttl_seconds \u5fc5\u987b\u4e3a\u6570\u5b57\u6216 null',
},
// ---------------------------------------------------------------------------
// Agent routes (src/routes/agents.ts)
// ---------------------------------------------------------------------------
'agent.missingAgentId': {
en: 'Missing required field "agent_id".',
zh: '\u7f3a\u5c11\u5fc5\u586b\u5b57\u6bb5 "agent_id"\u3002',
},
'agent.missingIntegrationType': {
en: 'Missing required field "integration_type".',
zh: '\u7f3a\u5c11\u5fc5\u586b\u5b57\u6bb5 "integration_type"\u3002',
},
'agent.missingReason': {
en: 'Missing required field "reason".',
zh: '\u7f3a\u5c11\u5fc5\u586b\u5b57\u6bb5 "reason"\u3002',
},
'agent.missingKid': {
en: 'Missing required field "kid".',
zh: '\u7f3a\u5c11\u5fc5\u586b\u5b57\u6bb5 "kid"\u3002',
},
// ---------------------------------------------------------------------------
// Operation routes (src/routes/operations.ts)
// ---------------------------------------------------------------------------
'operation.invalidBody': {
en: 'Invalid or empty request body.',
zh: '\u8bf7\u6c42\u4f53\u65e0\u6548\u6216\u4e3a\u7a7a\u3002',
},
// ---------------------------------------------------------------------------
// Export routes (src/routes/exports.ts)
// ---------------------------------------------------------------------------
'export.bodyRequired': {
en: 'Request body is required.',
zh: '\u8bf7\u6c42\u4f53\u4e3a\u5fc5\u586b\u9879\u3002',
},
'export.notFoundById': {
en: 'Export "{id}" not found.',
zh: '\u672a\u627e\u5230\u5bfc\u51fa\u8bb0\u5f55 "{id}"\u3002',
},
'export.notYetComplete': {
en: 'Export is not yet complete.',
zh: '\u5bfc\u51fa\u5c1a\u672a\u5b8c\u6210\u3002',
},
'export.fileNotFound': {
en: 'Export file not found in storage.',
zh: '\u5728\u5b58\u50a8\u4e2d\u672a\u627e\u5230\u5bfc\u51fa\u6587\u4ef6\u3002',
},
// ---------------------------------------------------------------------------
// Auth service (src/services/auth-service.ts)
// ---------------------------------------------------------------------------
'auth.emailPasswordRequired': {
en: 'Email and password (8-128 chars) are required',
zh: '\u7535\u5b50\u90ae\u4ef6\u548c\u5bc6\u7801\uff088-128 \u4e2a\u5b57\u7b26\uff09\u4e3a\u5fc5\u586b\u9879',
},
'auth.emailAlreadyRegistered': {
en: 'Email already registered',
zh: '\u7535\u5b50\u90ae\u4ef6\u5df2\u88ab\u6ce8\u518c',
},
'auth.invalidCredentials': {
en: 'Invalid email or password',
zh: '\u7535\u5b50\u90ae\u4ef6\u6216\u5bc6\u7801\u65e0\u6548',
},
'auth.accountSuspended': {
en: 'Account is suspended',
zh: '\u8d26\u6237\u5df2\u88ab\u505c\u7528',
},
'auth.userNotFound': {
en: 'User not found',
zh: '\u672a\u627e\u5230\u7528\u6237',
},
// ---------------------------------------------------------------------------
// Agent service (src/services/agent-service.ts)
// ---------------------------------------------------------------------------
'agent.alreadyExists': {
en: 'An agent with id "{id}" already exists.',
zh: 'ID \u4e3a "{id}" \u7684\u4ee3\u7406\u5df2\u5b58\u5728\u3002',
},
'agent.atLeastOneKey': {
en: 'At least one signing key must be provided.',
zh: '\u5fc5\u987b\u63d0\u4f9b\u81f3\u5c11\u4e00\u4e2a\u7b7e\u540d\u5bc6\u94a5\u3002',
},
'agent.notFound': {
en: 'Agent "{id}" not found.',
zh: '\u672a\u627e\u5230\u4ee3\u7406 "{id}"\u3002',
},
'agent.invalidIntegrationType': {
en: 'Invalid integration_type "{value}". Must be one of: {valid}.',
zh: '\u65e0\u6548\u7684 integration_type "{value}"\u3002\u5fc5\u987b\u4e3a\u4ee5\u4e0b\u4e4b\u4e00\uff1a{valid}\u3002',
},
'agent.alreadyFrozen': {
en: 'Agent "{id}" is already frozen.',
zh: '\u4ee3\u7406 "{id}" \u5df2\u88ab\u51bb\u7ed3\u3002',
},
'agent.permanentlyRevoked': {
en: 'Agent "{id}" has been permanently revoked.',
zh: '\u4ee3\u7406 "{id}" \u5df2\u88ab\u6c38\u4e45\u64a4\u9500\u3002',
},
'agent.notFrozen': {
en: 'Agent "{id}" is not frozen.',
zh: '\u4ee3\u7406 "{id}" \u672a\u88ab\u51bb\u7ed3\u3002',
},
'agent.permanentlyRevokedCannotUnfreeze': {
en: 'Agent "{id}" has been permanently revoked and cannot be unfrozen.',
zh: '\u4ee3\u7406 "{id}" \u5df2\u88ab\u6c38\u4e45\u64a4\u9500\uff0c\u65e0\u6cd5\u89e3\u51bb\u3002',
},
'agent.keyNotFound': {
en: 'Key "{kid}" not found for agent "{id}".',
zh: '\u672a\u627e\u5230\u4ee3\u7406 "{id}" \u7684\u5bc6\u94a5 "{kid}"\u3002',
},
'agent.keyAlreadyRevoked': {
en: 'Key "{kid}" is already revoked.',
zh: '\u5bc6\u94a5 "{kid}" \u5df2\u88ab\u64a4\u9500\u3002',
},
'agent.unsupportedAlgorithm': {
en: 'Unsupported key algorithm "{algorithm}". Only "ed25519" is supported.',
zh: '\u4e0d\u652f\u6301\u7684\u5bc6\u94a5\u7b97\u6cd5 "{algorithm}"\u3002\u4ec5\u652f\u6301 "ed25519"\u3002',
},
'agent.invalidPublicKeyLength': {
en: 'Public key "{kid}" must be 32 bytes (got {actual}).',
zh: '\u516c\u94a5 "{kid}" \u5fc5\u987b\u4e3a 32 \u5b57\u8282\uff08\u5b9e\u9645 {actual}\uff09\u3002',
},
'agent.invalidPublicKeyEncoding': {
en: 'Public key "{kid}" has invalid base64url encoding.',
zh: '\u516c\u94a5 "{kid}" \u7684 base64url \u7f16\u7801\u65e0\u6548\u3002',
},
// ---------------------------------------------------------------------------
// Operation service (src/services/operation-service.ts)
// ---------------------------------------------------------------------------
'operation.agentNotRegistered': {
en: 'Agent "{id}" is not registered.',
zh: '\u4ee3\u7406 "{id}" \u672a\u6ce8\u518c\u3002',
},
'operation.agentRevoked': {
en: 'The agent has been permanently revoked.',
zh: '\u8be5\u4ee3\u7406\u5df2\u88ab\u6c38\u4e45\u64a4\u9500\u3002',
},
'operation.keyNotFoundForAgent': {
en: 'Key "{kid}" not found for agent "{id}".',
zh: '\u672a\u627e\u5230\u4ee3\u7406 "{id}" \u7684\u5bc6\u94a5 "{kid}"\u3002',
},
'operation.keyRetired': {
en: 'The signing key has been retired.',
zh: '\u7b7e\u540d\u5bc6\u94a5\u5df2\u88ab\u505c\u7528\u3002',
},
'operation.prevHashMismatch': {
en: 'Expected prev_chain_hash "{expected}", got "{actual}".',
zh: '\u9884\u671f\u7684 prev_chain_hash \u4e3a "{expected}"\uff0c\u5b9e\u9645\u4e3a "{actual}"\u3002',
},
'operation.unsupportedVersion': {
en: 'Unsupported op_version "{version}". Only "1.0" is supported.',
zh: '\u4e0d\u652f\u6301\u7684 op_version "{version}"\u3002\u4ec5\u652f\u6301 "1.0"\u3002',
},
'operation.missingField': {
en: 'Missing or empty required field "{field}".',
zh: '\u7f3a\u5c11\u6216\u4e3a\u7a7a\u7684\u5fc5\u586b\u5b57\u6bb5 "{field}"\u3002',
},
'operation.nonceTooLong': {
en: 'Nonce exceeds maximum length of {max} characters.',
zh: '\u968f\u673a\u6570\u8d85\u8fc7\u4e86\u6700\u5927\u957f\u5ea6 {max} \u4e2a\u5b57\u7b26\u3002',
},
'operation.invalidIssuedAt': {
en: 'Invalid "issued_at" timestamp.',
zh: '\u65e0\u6548\u7684 "issued_at" \u65f6\u95f4\u6233\u3002',
},
'operation.missingTtl': {
en: 'Missing or invalid "ttl_ms".',
zh: '\u7f3a\u5c11\u6216\u65e0\u6548\u7684 "ttl_ms"\u3002',
},
'operation.ttlTooLow': {
en: 'ttl_ms must be at least {min}ms.',
zh: 'ttl_ms \u81f3\u5c11\u4e3a {min} \u6beb\u79d2\u3002',
},
'operation.ttlTooHigh': {
en: 'ttl_ms must not exceed {max}ms.',
zh: 'ttl_ms \u4e0d\u80fd\u8d85\u8fc7 {max} \u6beb\u79d2\u3002',
},
'operation.notFound': {
en: 'Operation "{id}" not found.',
zh: '\u672a\u627e\u5230\u64cd\u4f5c "{id}"\u3002',
},
// ---------------------------------------------------------------------------
// Export service (src/services/export-service.ts)
// ---------------------------------------------------------------------------
'export.invalidStartTime': {
en: 'Invalid "start_time" parameter.',
zh: '\u65e0\u6548\u7684 "start_time" \u53c2\u6570\u3002',
},
'export.invalidEndTime': {
en: 'Invalid "end_time" parameter.',
zh: '\u65e0\u6548\u7684 "end_time" \u53c2\u6570\u3002',
},
'export.startBeforeEnd': {
en: '"start_time" must be before "end_time".',
zh: '"start_time" \u5fc5\u987b\u65e9\u4e8e "end_time"\u3002',
},
'export.invalidFormat': {
en: 'Format must be "json" or "pdf".',
zh: '\u683c\u5f0f\u5fc5\u987b\u4e3a "json" \u6216 "pdf"\u3002',
},
'export.notFound': {
en: 'Export "{id}" not found.',
zh: '\u672a\u627e\u5230\u5bfc\u51fa\u8bb0\u5f55 "{id}"\u3002',
},
// ---------------------------------------------------------------------------
// Audit service (src/services/audit-service.ts)
// ---------------------------------------------------------------------------
'audit.invalidStartTime': {
en: 'Invalid "start_time" parameter.',
zh: '\u65e0\u6548\u7684 "start_time" \u53c2\u6570\u3002',
},
'audit.invalidEndTime': {
en: 'Invalid "end_time" parameter.',
zh: '\u65e0\u6548\u7684 "end_time" \u53c2\u6570\u3002',
},
'audit.invalidCursor': {
en: 'Invalid cursor value.',
zh: '\u65e0\u6548\u7684\u6e38\u6807\u503c\u3002',
},
// ---------------------------------------------------------------------------
// Epoch service (src/services/epoch-service.ts)
// ---------------------------------------------------------------------------
'epoch.notFound': {
en: 'Epoch "{id}" not found.',
zh: '\u672a\u627e\u5230\u65f6\u671f "{id}"\u3002',
},
};
/**
* Retrieve a translated message by key.
*
* Supports simple {param} placeholder interpolation via the optional
* `params` argument.
*
* Falls back to English if the requested language is missing, and
* ultimately falls back to the raw key if no translation exists at all.
*/
export function getMessage(
key: string,
lang: Lang = 'en',
params?: Record<string, string | number>,
): string {
let msg = messages[key]?.[lang] ?? messages[key]?.en ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
msg = msg.replaceAll(`{${k}}`, String(v));
}
}
return msg;
}
/**
* Detect the preferred language from an Accept-Language header value.
*
* Returns 'zh' if the header contains any Chinese locale indicator,
* otherwise defaults to 'en'.
*/
export function detectLanguage(acceptLanguage: string | null | undefined): Lang {
if (!acceptLanguage) return 'en';
if (acceptLanguage.includes('zh')) return 'zh';
return 'en';
}
+195
View File
@@ -0,0 +1,195 @@
/**
* Elydora API — Node.js entry point.
*/
import 'dotenv/config';
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import type { Env, AppVariables } from './types.js';
import { requestIdMiddleware } from './middleware/request-id.js';
import { i18nMiddleware } from './middleware/i18n.js';
import { globalErrorHandler } from './middleware/error-handler.js';
import { auth } from './routes/auth.js';
import { agents } from './routes/agents.js';
import { operations } from './routes/operations.js';
import { audit } from './routes/audit.js';
import { epochs } from './routes/epochs.js';
import { exports_ } from './routes/exports.js';
import { jwks } from './routes/jwks.js';
import { getMessage } from './i18n/messages.js';
// Adapter imports
import { PostgresAdapter } from './adapters/postgres.js';
import { MinioAdapter } from './adapters/minio.js';
import { RedisCacheAdapter } from './adapters/redis-cache.js';
import { BullMQAdapter } from './adapters/bullmq.js';
// ---------------------------------------------------------------------------
// Initialize adapters from environment variables
// ---------------------------------------------------------------------------
const db = new PostgresAdapter(process.env.DATABASE_URL!);
const evidence = new MinioAdapter(
process.env.MINIO_ENDPOINT!,
process.env.MINIO_ACCESS_KEY!,
process.env.MINIO_SECRET_KEY!,
process.env.MINIO_BUCKET ?? 'elydora-evidence',
);
const cache = new RedisCacheAdapter(process.env.REDIS_URL!);
const redisUrl = new URL(process.env.REDIS_URL!);
const queue = new BullMQAdapter({
host: redisUrl.hostname,
port: Number(redisUrl.port) || 6379,
});
// ---------------------------------------------------------------------------
// Build the env bindings object
// ---------------------------------------------------------------------------
const env: Env = {
ELYDORA_DB: db,
ELYDORA_EVIDENCE: evidence,
ELYDORA_CACHE: cache,
ELYDORA_QUEUE: queue,
ENVIRONMENT: process.env.ENVIRONMENT ?? 'production',
API_VERSION: process.env.API_VERSION ?? 'v1',
PROTOCOL_VERSION: process.env.PROTOCOL_VERSION ?? '1.0',
JWT_SECRET: process.env.JWT_SECRET!,
ELYDORA_SIGNING_KEY: process.env.ELYDORA_SIGNING_KEY!,
ALLOWED_ORIGINS: process.env.ALLOWED_ORIGINS ?? '',
TSA_URL: process.env.TSA_URL,
};
// ---------------------------------------------------------------------------
// Create the Hono application
// ---------------------------------------------------------------------------
const app = new Hono<{ Bindings: Env; Variables: AppVariables }>();
// Inject env bindings into every request context
app.use('/*', async (c, next) => {
// Copy all env bindings onto c.env
Object.assign(c.env, env);
await next();
});
// CORS
app.use(
'/*',
cors({
origin: (origin, c) => {
if (c.env.ENVIRONMENT === 'production') {
const raw = c.env.ALLOWED_ORIGINS ?? '';
const allowedOrigins = raw.split(',').map((s: string) => s.trim()).filter(Boolean);
return allowedOrigins.includes(origin) ? origin : '';
}
return origin;
},
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowHeaders: [
'Content-Type',
'Authorization',
'X-Elydora-Protocol-Version',
'X-Elydora-Signature',
'X-Request-Id',
],
exposeHeaders: [
'X-Request-Id',
'X-Elydora-Protocol-Version',
'X-RateLimit-Limit',
'X-RateLimit-Remaining',
'X-RateLimit-Reset',
],
maxAge: 86400,
credentials: true,
}),
);
// Security headers
app.use('/*', async (c, next) => {
await next();
c.header('X-Content-Type-Options', 'nosniff');
c.header('X-Frame-Options', 'DENY');
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
c.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
c.header('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'");
if (c.env.ENVIRONMENT === 'production') {
c.header('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');
}
});
// Request ID
app.use('/*', requestIdMiddleware);
// Language detection
app.use('/*', i18nMiddleware);
// Global error handler
app.onError(globalErrorHandler);
// Health check
app.get('/v1/health', (c) => {
return c.json(
{
status: 'healthy',
version: c.env.API_VERSION,
protocol_version: c.env.PROTOCOL_VERSION,
timestamp: Date.now(),
},
200,
);
});
// Route groups
app.route('/.well-known/elydora/jwks.json', jwks);
app.route('/v1/auth', auth);
app.route('/v1/agents', agents);
app.route('/v1/operations', operations);
app.route('/v1/audit', audit);
app.route('/v1/epochs', epochs);
app.route('/v1/exports', exports_);
// 404 fallback
app.notFound((c) => {
const requestId = c.get('request_id') ?? 'unknown';
const lang = c.get('lang') ?? 'en';
return c.json(
{
error: {
code: 'NOT_FOUND',
message: getMessage('notFound.resource', lang),
request_id: requestId,
},
},
404,
);
});
// ---------------------------------------------------------------------------
// Start the server
// ---------------------------------------------------------------------------
const port = Number(process.env.PORT) || 8787;
await cache.connect();
console.log(`Elydora API server starting on port ${port}`);
serve({ fetch: app.fetch, port }, (info) => {
console.log(`Elydora API server listening on http://localhost:${info.port}`);
});
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('SIGTERM received, shutting down...');
await cache.close();
await db.close();
process.exit(0);
});
process.on('SIGINT', async () => {
console.log('SIGINT received, shutting down...');
await cache.close();
await db.close();
process.exit(0);
});
+147
View File
@@ -0,0 +1,147 @@
/**
* JWT authentication middleware.
*
* Verifies Bearer tokens in the Authorization header using HMAC-SHA256.
* On success, extracts `org_id`, `role`, and `sub` (actor) claims and
* stores them in the Hono context variables for downstream handlers.
*
* Token structure (payload):
* {
* "sub": "user_abc123", // actor identifier
* "org_id": "org_acme", // organization scope
* "role": "org_owner", // RBAC role
* "iat": 1700000000, // issued-at (seconds)
* "exp": 1700003600 // expiration (seconds)
* }
*/
import type { MiddlewareHandler } from 'hono';
import type { RbacRole } from '../shared/index.js';
import type { Env, AppVariables } from '../types.js';
import { AppError } from './error-handler.js';
import { base64urlDecode } from '../utils/crypto.js';
/** Minimal JWT payload shape we require. */
interface JWTPayload {
sub: string;
org_id: string;
role: RbacRole;
iat: number;
exp: number;
}
/**
* Verify a JWT using HMAC-SHA256 and return its payload.
*/
async function verifyJWT(token: string, secret: string): Promise<JWTPayload> {
const parts = token.split('.');
if (parts.length !== 3) {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.malformedJwt' });
}
const headerB64 = parts[0]!;
const payloadB64 = parts[1]!;
const signatureB64 = parts[2]!;
// Validate JWT header — only HS256 is accepted
try {
const headerBytes = base64urlDecode(headerB64);
const header = JSON.parse(new TextDecoder().decode(headerBytes)) as { alg?: string; typ?: string };
if (header.alg !== 'HS256') {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.unsupportedAlgorithm' });
}
} catch (e) {
if (e instanceof AppError) throw e;
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.malformedJwt' });
}
// Import the HMAC key
const encoder = new TextEncoder();
const keyData = encoder.encode(secret);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify'],
);
// Verify signature over "header.payload"
const signedInput = encoder.encode(`${headerB64}.${payloadB64}`);
const signatureBytes = base64urlDecode(signatureB64);
const valid = await crypto.subtle.verify('HMAC', cryptoKey, signatureBytes as unknown as Uint8Array<ArrayBuffer>, signedInput as unknown as Uint8Array<ArrayBuffer>);
if (!valid) {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.invalidSignature' });
}
// Decode and validate payload
const payloadBytes = base64urlDecode(payloadB64);
const payloadJson = new TextDecoder().decode(payloadBytes);
let payload: JWTPayload;
try {
payload = JSON.parse(payloadJson);
} catch {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.malformedPayload' });
}
// Validate required claims
if (!payload.sub || typeof payload.sub !== 'string') {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingSub' });
}
if (!payload.org_id || typeof payload.org_id !== 'string') {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingOrgId' });
}
if (!payload.role || typeof payload.role !== 'string') {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingRole' });
}
// Check expiration
const nowSeconds = Math.floor(Date.now() / 1000);
if (typeof payload.exp !== 'number') {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingExp' });
}
if (payload.exp < nowSeconds) {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.expired' });
}
return payload;
}
/**
* Authentication middleware.
*
* Extracts the Bearer token, verifies it, and sets org_id, role, and
* actor on the context. Should be applied to all routes that require
* authentication.
*/
export const authMiddleware: MiddlewareHandler<{
Bindings: Env;
Variables: AppVariables;
}> = async (c, next) => {
const authHeader = c.req.header('Authorization');
if (!authHeader) {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingHeader' });
}
if (!authHeader.startsWith('Bearer ')) {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.bearerRequired' });
}
const token = authHeader.slice(7).trim();
if (!token) {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.emptyToken' });
}
const payload = await verifyJWT(token, c.env.JWT_SECRET);
c.set('org_id', payload.org_id);
c.set('role', payload.role);
c.set('actor', payload.sub);
await next();
};
@@ -0,0 +1,166 @@
/**
* Global error handler middleware.
*
* Catches all uncaught exceptions and formats them as a standardized
* ErrorResponse with the appropriate HTTP status code, error code,
* and the request's unique identifier for tracing.
*
* When an AppError carries a `messageKey`, the handler resolves it to
* the correct language using the `lang` context variable set by the
* i18n middleware. This allows services to throw errors with i18n keys
* without needing direct access to the Hono context.
*/
import type { ErrorHandler } from 'hono';
import type { ErrorCode, ErrorResponse } from '../shared/index.js';
import { ERROR_CODES } from '../shared/index.js';
import type { Env, AppVariables } from '../types.js';
import { getMessage } from '../i18n/messages.js';
import type { Lang } from '../i18n/messages.js';
/**
* Maps each ErrorCode to its corresponding i18n message key.
*
* This is used when an AppError is thrown with only an error code
* (no explicit message or i18n key), so the global error handler
* can still resolve a translated default message.
*/
const ERROR_CODE_TO_I18N_KEY: Record<ErrorCode, string> = {
INVALID_SIGNATURE: 'error.invalidSignature',
UNKNOWN_AGENT: 'error.unknownAgent',
KEY_REVOKED: 'error.keyRevoked',
AGENT_FROZEN: 'error.agentFrozen',
TTL_EXPIRED: 'error.ttlExpired',
REPLAY_DETECTED: 'error.replayDetected',
PREV_HASH_MISMATCH: 'error.prevHashMismatch',
PAYLOAD_TOO_LARGE: 'error.payloadTooLarge',
RATE_LIMITED: 'error.rateLimited',
INTERNAL_ERROR: 'error.internalError',
UNAUTHORIZED: 'error.unauthorized',
FORBIDDEN: 'error.forbidden',
NOT_FOUND: 'error.notFound',
VALIDATION_ERROR: 'error.validationError',
};
/**
* Custom error class that carries an HTTP status and Elydora error code.
*
* Supports three modes:
* 1. Code-only: `new AppError(status, code)` — uses ErrorCode default message
* 2. i18n key: `new AppError(status, code, { key: 'msg.key', params: {...} })` —
* the message is resolved at response time from the translation map
* 3. Raw string: `new AppError(status, code, 'raw message')` — message is used as-is
* (should be avoided in new code; prefer mode 2)
*/
export class AppError extends Error {
public readonly statusCode: number;
public readonly errorCode: ErrorCode;
public readonly details?: Record<string, unknown>;
public readonly messageKey?: string;
public readonly messageParams?: Record<string, string | number>;
/** True when the error was created with only an error code (no custom message). */
public readonly usesDefaultMessage: boolean;
constructor(
statusCode: number,
errorCode: ErrorCode,
messageOrOpts?: string | { key: string; params?: Record<string, string | number> },
details?: Record<string, unknown>,
) {
if (typeof messageOrOpts === 'object' && messageOrOpts !== null) {
// i18n mode — store the key; resolve later in the error handler
super(getMessage(messageOrOpts.key, 'en', messageOrOpts.params));
this.messageKey = messageOrOpts.key;
this.messageParams = messageOrOpts.params;
this.usesDefaultMessage = false;
} else if (messageOrOpts === undefined) {
// Code-only mode — will be resolved via ERROR_CODE_TO_I18N_KEY
super(ERROR_CODES[errorCode]);
this.usesDefaultMessage = true;
} else {
// Raw string mode (legacy)
super(messageOrOpts);
this.usesDefaultMessage = false;
}
this.name = 'AppError';
this.statusCode = statusCode;
this.errorCode = errorCode;
this.details = details;
}
/**
* Resolve the user-facing message for a given language.
*
* Resolution order:
* 1. If a messageKey was provided, use that with the translation map.
* 2. If the error used the default ErrorCode message, translate via
* the ERROR_CODE_TO_I18N_KEY mapping.
* 3. Otherwise return the raw message string.
*/
resolveMessage(lang: Lang): string {
if (this.messageKey) {
return getMessage(this.messageKey, lang, this.messageParams);
}
if (this.usesDefaultMessage) {
const i18nKey = ERROR_CODE_TO_I18N_KEY[this.errorCode];
if (i18nKey) {
return getMessage(i18nKey, lang);
}
}
return this.message;
}
}
/**
* Build a standardised error response body.
*
* When `lang` is provided and no explicit `message` is given, the
* ErrorCode default message is resolved from the translation map.
*/
export function buildErrorResponse(
code: ErrorCode,
requestId: string,
message?: string,
details?: Record<string, unknown>,
lang?: Lang,
): ErrorResponse {
let resolvedMessage = message;
if (!resolvedMessage) {
const i18nKey = ERROR_CODE_TO_I18N_KEY[code];
resolvedMessage = i18nKey ? getMessage(i18nKey, lang ?? 'en') : ERROR_CODES[code];
}
return {
error: {
code,
message: resolvedMessage,
request_id: requestId,
...(details ? { details } : {}),
},
};
}
/**
* Hono error handler that converts all errors into the ErrorResponse format.
*
* Resolves i18n message keys using the `lang` context variable set by
* the i18n middleware.
*/
export const globalErrorHandler: ErrorHandler<{
Bindings: Env;
Variables: AppVariables;
}> = (err, c) => {
const requestId = c.get('request_id') ?? 'unknown';
const lang: Lang = c.get('lang') ?? 'en';
if (err instanceof AppError) {
const resolvedMessage = err.resolveMessage(lang);
const body = buildErrorResponse(err.errorCode, requestId, resolvedMessage, err.details);
return c.json(body, err.statusCode as 400);
}
// Log unexpected errors for observability
console.error(`[${requestId}] Unhandled error:`, err);
const body = buildErrorResponse('INTERNAL_ERROR', requestId, undefined, undefined, lang);
return c.json(body, 500);
};
+22
View File
@@ -0,0 +1,22 @@
/**
* i18n middleware — language detection from Accept-Language header.
*
* Reads the Accept-Language request header, detects the preferred
* language (English or Chinese), and stores it in the Hono context
* so that all downstream handlers and services can access it via
* `c.get('lang')`.
*/
import type { MiddlewareHandler } from 'hono';
import type { Env, AppVariables } from '../types.js';
import { detectLanguage } from '../i18n/messages.js';
export const i18nMiddleware: MiddlewareHandler<{
Bindings: Env;
Variables: AppVariables;
}> = async (c, next) => {
const acceptLanguage = c.req.header('Accept-Language');
const lang = detectLanguage(acceptLanguage);
c.set('lang', lang);
await next();
};
+67
View File
@@ -0,0 +1,67 @@
/**
* Role-based access control (RBAC) middleware.
*
* The Elydora console defines five roles with a clear privilege hierarchy:
*
* org_owner > security_admin > compliance_auditor > readonly_investigator
* integration_engineer (parallel to compliance_auditor, agent-management focused)
*
* This middleware checks whether the role extracted by the auth middleware
* satisfies the minimum required role for a given route.
*/
import type { MiddlewareHandler } from 'hono';
import type { RbacRole } from '../shared/index.js';
import type { Env, AppVariables } from '../types.js';
import { AppError } from './error-handler.js';
/**
* Privilege level map. Higher number = more privilege.
* integration_engineer is a special role with limited admin capabilities
* for agent management only.
*/
const ROLE_LEVEL: Record<RbacRole, number> = {
readonly_investigator: 10,
integration_engineer: 20,
compliance_auditor: 30,
security_admin: 40,
org_owner: 50,
};
/**
* Check if a role has at least the given privilege level.
*/
function hasMinimumRole(userRole: RbacRole, requiredRole: RbacRole): boolean {
return ROLE_LEVEL[userRole] >= ROLE_LEVEL[requiredRole];
}
/**
* Create an RBAC middleware that requires a minimum role.
*
* Usage:
* app.post('/v1/agents/:id/freeze', requireRole('security_admin'), handler);
*
* @param minimumRole - The minimum RBAC role required to access the endpoint
*/
export function requireRole(minimumRole: RbacRole): MiddlewareHandler<{
Bindings: Env;
Variables: AppVariables;
}> {
return async (c, next) => {
const userRole = c.get('role');
if (!userRole) {
throw new AppError(401, 'UNAUTHORIZED', { key: 'rbac.noRole' });
}
if (!hasMinimumRole(userRole, minimumRole)) {
throw new AppError(
403,
'FORBIDDEN',
{ key: 'rbac.insufficientPermissions' },
);
}
await next();
};
}
@@ -0,0 +1,22 @@
/**
* Request ID middleware.
*
* Generates a UUIDv7 for every incoming request and stores it in the
* Hono context variables. The ID is also set as the X-Request-Id
* response header so clients can reference it when reporting issues.
*/
import type { MiddlewareHandler } from 'hono';
import type { Env, AppVariables } from '../types.js';
import { generateUUIDv7 } from '../utils/uuid.js';
export const requestIdMiddleware: MiddlewareHandler<{
Bindings: Env;
Variables: AppVariables;
}> = async (c, next) => {
const requestId = generateUUIDv7();
c.set('request_id', requestId);
c.header('X-Request-Id', requestId);
await next();
};
+216
View File
@@ -0,0 +1,216 @@
/**
* Agent routes — registration, lookup, freezing, and key revocation.
*/
import { Hono } from 'hono';
import type { Env, AppVariables } from '../types.js';
import type { RegisterAgentRequest, UpdateAgentRequest, FreezeAgentRequest, UnfreezeAgentRequest, RevokeAgentRequest } from '../shared/index.js';
import { authMiddleware } from '../middleware/auth.js';
import { requireRole } from '../middleware/rbac.js';
import { AppError } from '../middleware/error-handler.js';
import * as agentService from '../services/agent-service.js';
const agents = new Hono<{ Bindings: Env; Variables: AppVariables }>();
// All agent routes require authentication
agents.use('/*', authMiddleware);
// ---------------------------------------------------------------------------
// POST /v1/agents/register
// ---------------------------------------------------------------------------
agents.post(
'/register',
requireRole('integration_engineer'),
async (c) => {
const body = await c.req.json<RegisterAgentRequest>();
if (!body || !body.agent_id) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.missingAgentId' });
}
const orgId = c.get('org_id');
const actor = c.get('actor');
const result = await agentService.registerAgent(c.env.ELYDORA_DB, body, orgId, actor);
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
return c.json(result, 201);
},
);
// ---------------------------------------------------------------------------
// GET /v1/agents
// ---------------------------------------------------------------------------
agents.get(
'/',
requireRole('readonly_investigator'),
async (c) => {
const orgId = c.get('org_id');
const result = await agentService.listAgents(c.env.ELYDORA_DB, orgId);
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
return c.json(result, 200);
},
);
// ---------------------------------------------------------------------------
// GET /v1/agents/:agent_id
// ---------------------------------------------------------------------------
agents.get(
'/:agent_id',
requireRole('readonly_investigator'),
async (c) => {
const agentId = c.req.param('agent_id');
const orgId = c.get('org_id');
const result = await agentService.getAgent(c.env.ELYDORA_DB, agentId, orgId);
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
return c.json(result, 200);
},
);
// ---------------------------------------------------------------------------
// DELETE /v1/agents/:agent_id
// ---------------------------------------------------------------------------
agents.delete(
'/:agent_id',
requireRole('security_admin'),
async (c) => {
const agentId = c.req.param('agent_id');
const orgId = c.get('org_id');
const actor = c.get('actor');
await agentService.deleteAgent(c.env.ELYDORA_DB, agentId, orgId, actor);
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
return c.json({ deleted: true }, 200);
},
);
// ---------------------------------------------------------------------------
// PATCH /v1/agents/:agent_id
// ---------------------------------------------------------------------------
agents.patch(
'/:agent_id',
requireRole('integration_engineer'),
async (c) => {
const agentId = c.req.param('agent_id');
const body = await c.req.json<UpdateAgentRequest>();
if (!body || !body.integration_type || typeof body.integration_type !== 'string') {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.missingIntegrationType' });
}
const orgId = c.get('org_id');
const actor = c.get('actor');
const result = await agentService.updateAgentIntegrationType(
c.env.ELYDORA_DB,
agentId,
body.integration_type,
orgId,
actor,
);
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
return c.json(result, 200);
},
);
// ---------------------------------------------------------------------------
// POST /v1/agents/:agent_id/freeze
// ---------------------------------------------------------------------------
agents.post(
'/:agent_id/freeze',
requireRole('security_admin'),
async (c) => {
const agentId = c.req.param('agent_id');
const body = await c.req.json<FreezeAgentRequest>();
if (!body || !body.reason || typeof body.reason !== 'string' || body.reason.trim().length === 0) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.missingReason' });
}
const orgId = c.get('org_id');
const actor = c.get('actor');
const result = await agentService.freezeAgent(
c.env.ELYDORA_DB,
agentId,
body.reason,
orgId,
actor,
);
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
return c.json(result, 200);
},
);
// ---------------------------------------------------------------------------
// POST /v1/agents/:agent_id/unfreeze
// ---------------------------------------------------------------------------
agents.post(
'/:agent_id/unfreeze',
requireRole('security_admin'),
async (c) => {
const agentId = c.req.param('agent_id');
const body = await c.req.json<UnfreezeAgentRequest>();
if (!body || !body.reason || typeof body.reason !== 'string' || body.reason.trim().length === 0) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.missingReason' });
}
const orgId = c.get('org_id');
const actor = c.get('actor');
const result = await agentService.unfreezeAgent(
c.env.ELYDORA_DB,
agentId,
body.reason,
orgId,
actor,
);
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
return c.json(result, 200);
},
);
// ---------------------------------------------------------------------------
// POST /v1/agents/:agent_id/revoke
// ---------------------------------------------------------------------------
agents.post(
'/:agent_id/revoke',
requireRole('security_admin'),
async (c) => {
const agentId = c.req.param('agent_id');
const body = await c.req.json<RevokeAgentRequest>();
if (!body || !body.kid || typeof body.kid !== 'string') {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.missingKid' });
}
if (!body.reason || typeof body.reason !== 'string' || body.reason.trim().length === 0) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.missingReason' });
}
const orgId = c.get('org_id');
const actor = c.get('actor');
const result = await agentService.revokeKey(
c.env.ELYDORA_DB,
agentId,
body.kid,
body.reason,
orgId,
actor,
);
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
return c.json(result, 200);
},
);
export { agents };
+38
View File
@@ -0,0 +1,38 @@
/**
* Audit routes — query the tamper-evident audit log.
*
* Per the OpenAPI spec, audit queries use POST /v1/audit/query with
* filter parameters in the request body. This avoids URL length limits
* and supports complex filter expressions.
*/
import { Hono } from 'hono';
import type { Env, AppVariables } from '../types.js';
import type { AuditQueryRequest } from '../shared/index.js';
import { authMiddleware } from '../middleware/auth.js';
import { requireRole } from '../middleware/rbac.js';
import * as auditService from '../services/audit-service.js';
const audit = new Hono<{ Bindings: Env; Variables: AppVariables }>();
// All audit routes require authentication and at least compliance_auditor role
audit.use('/*', authMiddleware);
// ---------------------------------------------------------------------------
// POST /v1/audit/query — Query the audit log
// ---------------------------------------------------------------------------
audit.post(
'/query',
requireRole('compliance_auditor'),
async (c) => {
const body = await c.req.json<AuditQueryRequest>();
const orgId = c.get('org_id');
const result = await auditService.queryAudit(c.env.ELYDORA_DB, body ?? {}, orgId);
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
return c.json(result, 200);
},
);
export { audit };
+103
View File
@@ -0,0 +1,103 @@
/**
* Auth routes — registration, login, profile retrieval, and token refresh.
*
* /register and /login are public (no auth required).
* /me and /refresh require a valid JWT.
*/
import { Hono } from 'hono';
import type { Env, AppVariables } from '../types.js';
import { authMiddleware } from '../middleware/auth.js';
import { registerUser, loginUser, getMe, issueJWT, issueApiToken } from '../services/auth-service.js';
import type { RbacRole } from '../shared/index.js';
import { DEFAULT_JWT_TTL_SECONDS } from '../shared/index.js';
import { getMessage } from '../i18n/messages.js';
const auth = new Hono<{ Bindings: Env; Variables: AppVariables }>();
// ---------------------------------------------------------------------------
// POST /v1/auth/register — Create a new user and organization
// ---------------------------------------------------------------------------
auth.post('/register', async (c) => {
const body = await c.req.json();
const { email, password, display_name, org_name } = body;
const result = await registerUser(
c.env.ELYDORA_DB,
c.env.JWT_SECRET,
email,
password,
display_name,
org_name,
);
return c.json(result, 201);
});
// ---------------------------------------------------------------------------
// POST /v1/auth/login — Authenticate and receive a JWT
// ---------------------------------------------------------------------------
auth.post('/login', async (c) => {
const body = await c.req.json();
const { email, password } = body;
const result = await loginUser(c.env.ELYDORA_DB, c.env.JWT_SECRET, email, password);
return c.json(result, 200);
});
// ---------------------------------------------------------------------------
// GET /v1/auth/me — Get current user profile (auth required)
// ---------------------------------------------------------------------------
auth.get('/me', authMiddleware, async (c) => {
const userId = c.get('actor');
const user = await getMe(c.env.ELYDORA_DB, userId);
return c.json({ user }, 200);
});
// ---------------------------------------------------------------------------
// POST /v1/auth/refresh — Issue a new JWT (auth required)
// ---------------------------------------------------------------------------
auth.post('/refresh', authMiddleware, async (c) => {
const now = Date.now();
const token = await issueJWT(
{
sub: c.get('actor'),
org_id: c.get('org_id'),
role: c.get('role') as RbacRole,
iat: Math.floor(now / 1000),
exp: Math.floor(now / 1000) + DEFAULT_JWT_TTL_SECONDS,
},
c.env.JWT_SECRET,
);
return c.json({ token }, 200);
});
// ---------------------------------------------------------------------------
// POST /v1/auth/token — Issue an API token with custom TTL (auth required)
// ---------------------------------------------------------------------------
auth.post('/token', authMiddleware, async (c) => {
const body = await c.req.json();
const { ttl_seconds } = body;
if (ttl_seconds !== null && ttl_seconds !== undefined) {
if (typeof ttl_seconds !== 'number' || ttl_seconds <= 0) {
const lang = c.get('lang') ?? 'en';
return c.json({ error: { code: 'VALIDATION_ERROR', message: getMessage('auth.ttlMustBeNumber', lang), request_id: '' } }, 400);
}
}
const result = await issueApiToken(
c.get('actor'),
c.get('org_id'),
c.get('role') as RbacRole,
c.env.JWT_SECRET,
ttl_seconds ?? null,
);
return c.json(result, 200);
});
export { auth };
+48
View File
@@ -0,0 +1,48 @@
/**
* Epoch routes — retrieve epoch root records with optional TSA anchors.
*/
import { Hono } from 'hono';
import type { Env, AppVariables } from '../types.js';
import { authMiddleware } from '../middleware/auth.js';
import { requireRole } from '../middleware/rbac.js';
import * as epochService from '../services/epoch-service.js';
const epochs = new Hono<{ Bindings: Env; Variables: AppVariables }>();
// All epoch routes require authentication
epochs.use('/*', authMiddleware);
// ---------------------------------------------------------------------------
// GET /v1/epochs — List all epochs for the organization
// ---------------------------------------------------------------------------
epochs.get('/', requireRole('readonly_investigator'), async (c) => {
const orgId = c.get('org_id');
const result = await epochService.listEpochs(c.env.ELYDORA_DB, orgId);
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
return c.json(result, 200);
});
// ---------------------------------------------------------------------------
// GET /v1/epochs/:epoch_id — Retrieve an epoch root
// ---------------------------------------------------------------------------
epochs.get(
'/:epoch_id',
requireRole('readonly_investigator'),
async (c) => {
const epochId = c.req.param('epoch_id');
const orgId = c.get('org_id');
const result = await epochService.getEpoch(
c.env.ELYDORA_DB,
c.env.ELYDORA_EVIDENCE,
epochId,
orgId,
);
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
return c.json(result, 200);
},
);
export { epochs };
+130
View File
@@ -0,0 +1,130 @@
/**
* Export routes — create and retrieve compliance export jobs.
*/
import { Hono } from 'hono';
import type { Env, AppVariables } from '../types.js';
import type { CreateExportRequest } from '../shared/index.js';
import { authMiddleware } from '../middleware/auth.js';
import { requireRole } from '../middleware/rbac.js';
import { AppError } from '../middleware/error-handler.js';
import * as exportService from '../services/export-service.js';
const exports_ = new Hono<{ Bindings: Env; Variables: AppVariables }>();
// All export routes require authentication
exports_.use('/*', authMiddleware);
// ---------------------------------------------------------------------------
// GET /v1/exports — List all exports for the organization
// ---------------------------------------------------------------------------
exports_.get('/', requireRole('compliance_auditor'), async (c) => {
const orgId = c.get('org_id');
const result = await exportService.listExports(c.env.ELYDORA_DB, orgId);
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
return c.json(result, 200);
});
// ---------------------------------------------------------------------------
// POST /v1/exports — Create a compliance export job
// ---------------------------------------------------------------------------
exports_.post(
'/',
requireRole('compliance_auditor'),
async (c) => {
const body = await c.req.json<CreateExportRequest>();
if (!body) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'export.bodyRequired' });
}
const orgId = c.get('org_id');
const actor = c.get('actor');
const result = await exportService.createExport(
c.env.ELYDORA_DB,
c.env.ELYDORA_QUEUE,
body,
orgId,
actor,
);
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
return c.json(result, 201);
},
);
// ---------------------------------------------------------------------------
// GET /v1/exports/:export_id — Retrieve export status + download URL
// ---------------------------------------------------------------------------
exports_.get(
'/:export_id',
requireRole('compliance_auditor'),
async (c) => {
const exportId = c.req.param('export_id');
const orgId = c.get('org_id');
const result = await exportService.getExport(
c.env.ELYDORA_DB,
c.env.ELYDORA_EVIDENCE,
exportId,
orgId,
);
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
return c.json(result, 200);
},
);
// ---------------------------------------------------------------------------
// GET /v1/exports/:export_id/download — Stream the export file from R2
// ---------------------------------------------------------------------------
exports_.get(
'/:export_id/download',
requireRole('compliance_auditor'),
async (c) => {
const exportId = c.req.param('export_id');
const orgId = c.get('org_id');
// Fetch the export record
const exportRecord = await c.env.ELYDORA_DB
.prepare('SELECT * FROM exports WHERE export_id = ? AND org_id = ?')
.bind(exportId, orgId)
.first<{ status: string; r2_export_key: string | null; query_params: string }>();
if (!exportRecord) {
throw new AppError(404, 'NOT_FOUND', { key: 'export.notFoundById', params: { id: exportId } });
}
if (exportRecord.status !== 'done' || !exportRecord.r2_export_key) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'export.notYetComplete' });
}
const object = await c.env.ELYDORA_EVIDENCE.get(exportRecord.r2_export_key);
if (!object) {
throw new AppError(404, 'NOT_FOUND', { key: 'export.fileNotFound' });
}
// Determine file extension from the stored query_params
let ext = '';
const qp = exportRecord as { query_params?: string };
if (qp.query_params) {
try {
const params = JSON.parse(qp.query_params);
ext = params.format === 'pdf' ? '.pdf' : '.json';
} catch { /* ignore */ }
}
const headers = new Headers();
headers.set('Content-Type', object.httpMetadata?.contentType ?? 'application/octet-stream');
headers.set('Content-Disposition', `attachment; filename="export-${exportId}${ext}"`);
if (object.size !== undefined) {
headers.set('Content-Length', String(object.size));
}
return new Response(object.body, { headers });
},
);
export { exports_ };
+43
View File
@@ -0,0 +1,43 @@
/**
* JWKS route — public key discovery endpoint.
*
* Returns the JSON Web Key Set containing the Elydora server's public
* keys used for signing attestation receipts (EAR) and epoch roots (EER).
* External verifiers can use these keys to independently validate
* signatures without contacting the Elydora API.
*
* This endpoint requires NO authentication (per OpenAPI spec and RFC 7517).
*/
import { Hono } from 'hono';
import type { Env, AppVariables } from '../types.js';
import type { JWKSResponse } from '../shared/index.js';
import { deriveEd25519PublicKey } from '../utils/crypto.js';
const jwks = new Hono<{ Bindings: Env; Variables: AppVariables }>();
// ---------------------------------------------------------------------------
// GET /.well-known/elydora/jwks.json — Retrieve the platform JWKS
// ---------------------------------------------------------------------------
jwks.get('/', async (c) => {
// Derive the public key from the server signing key
const publicKeyBase64url = await deriveEd25519PublicKey(c.env.ELYDORA_SIGNING_KEY);
const response: JWKSResponse = {
keys: [
{
kty: 'OKP',
crv: 'Ed25519',
x: publicKeyBase64url,
kid: 'elydora-server-key-v1',
use: 'sig',
alg: 'EdDSA',
},
],
};
c.header('Cache-Control', 'public, max-age=3600');
return c.json(response, 200);
});
export { jwks };
+90
View File
@@ -0,0 +1,90 @@
/**
* Operation routes — submit, retrieve, and verify signed operation records.
*/
import { Hono } from 'hono';
import type { Env, AppVariables } from '../types.js';
import type { EOR } from '../shared/index.js';
import { authMiddleware } from '../middleware/auth.js';
import { requireRole } from '../middleware/rbac.js';
import { AppError } from '../middleware/error-handler.js';
import * as operationService from '../services/operation-service.js';
const operations = new Hono<{ Bindings: Env; Variables: AppVariables }>();
// ---------------------------------------------------------------------------
// POST /v1/operations — Submit a signed EOR
// ---------------------------------------------------------------------------
// Operation submission uses the EOR's embedded signature for authentication
// rather than a Bearer JWT. The agent authenticates via its Ed25519 signature.
// We still apply auth middleware to get the org context.
operations.post(
'/',
authMiddleware,
requireRole('integration_engineer'),
async (c) => {
const eorBody = await c.req.json<EOR>();
const orgId = c.get('org_id');
const eor = { ...eorBody, org_id: orgId };
if (!eor || !eor.operation_id) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.invalidBody' });
}
const result = await operationService.submitOperation(
c.env.ELYDORA_DB,
c.env.ELYDORA_EVIDENCE,
c.env.ELYDORA_CACHE,
c.env.ELYDORA_QUEUE,
eor,
c.env.ELYDORA_SIGNING_KEY,
);
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
return c.json(result, 202);
},
);
// ---------------------------------------------------------------------------
// GET /v1/operations/:operation_id — Retrieve an operation
// ---------------------------------------------------------------------------
operations.get(
'/:operation_id',
authMiddleware,
requireRole('readonly_investigator'),
async (c) => {
const operationId = c.req.param('operation_id');
const orgId = c.get('org_id');
const result = await operationService.getOperation(c.env.ELYDORA_DB, c.env.ELYDORA_EVIDENCE, operationId, orgId);
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
return c.json(result, 200);
},
);
// ---------------------------------------------------------------------------
// POST /v1/operations/:operation_id/verify — Verify operation integrity
// ---------------------------------------------------------------------------
operations.post(
'/:operation_id/verify',
authMiddleware,
requireRole('readonly_investigator'),
async (c) => {
const operationId = c.req.param('operation_id');
const orgId = c.get('org_id');
const result = await operationService.verifyOperation(
c.env.ELYDORA_DB,
c.env.ELYDORA_EVIDENCE,
operationId,
orgId,
);
c.header('X-Elydora-Protocol-Version', c.env.PROTOCOL_VERSION);
return c.json(result, 200);
},
);
export { operations };
+116
View File
@@ -0,0 +1,116 @@
/**
* Epoch scheduler — runs every 5 minutes to create Merkle tree rollups.
*
* Run as a separate Node.js process: node dist/scheduler.js
*/
import 'dotenv/config';
import cron from 'node-cron';
import { PostgresAdapter } from './adapters/postgres.js';
import { MinioAdapter } from './adapters/minio.js';
import { RedisCacheAdapter } from './adapters/redis-cache.js';
import { BullMQAdapter } from './adapters/bullmq.js';
import { createEpoch } from './services/epoch-service.js';
import { DEFAULT_EPOCH_INTERVAL_MS } from './shared/index.js';
// ---------------------------------------------------------------------------
// Initialize adapters
// ---------------------------------------------------------------------------
const db = new PostgresAdapter(process.env.DATABASE_URL!);
const evidence = new MinioAdapter(
process.env.MINIO_ENDPOINT!,
process.env.MINIO_ACCESS_KEY!,
process.env.MINIO_SECRET_KEY!,
process.env.MINIO_BUCKET ?? 'elydora-evidence',
);
const cache = new RedisCacheAdapter(process.env.REDIS_URL!);
const redisUrl = new URL(process.env.REDIS_URL!);
const queue = new BullMQAdapter({
host: redisUrl.hostname,
port: Number(redisUrl.port) || 6379,
});
await cache.connect();
// ---------------------------------------------------------------------------
// Epoch creation logic (mirrors the Cloudflare scheduled handler)
// ---------------------------------------------------------------------------
async function runEpochCreation(): Promise<void> {
const now = Date.now();
const interval = DEFAULT_EPOCH_INTERVAL_MS;
const epochEnd = Math.floor(now / interval) * interval;
const epochStart = epochEnd - interval;
const { results: orgs } = await db
.prepare(
'SELECT DISTINCT org_id FROM operations WHERE created_at >= ? AND created_at < ?',
)
.bind(epochStart, epochEnd)
.all<{ org_id: string }>();
if (!orgs || orgs.length === 0) return;
for (const { org_id } of orgs) {
try {
const epoch = await createEpoch(
db,
evidence,
process.env.ELYDORA_SIGNING_KEY!,
org_id,
epochStart,
epochEnd,
);
if (epoch) {
try {
await queue.send({
type: 'tsa',
epoch_id: epoch.epoch_id,
org_id: epoch.org_id,
root_hash: epoch.root_hash,
r2_epoch_key: epoch.r2_epoch_key,
});
} catch (tsaError) {
console.error(
`Failed to enqueue TSA anchor for epoch ${epoch.epoch_id}:`,
tsaError instanceof Error ? tsaError.message : String(tsaError),
);
}
}
} catch (error) {
console.error(
`Failed to create epoch for org ${org_id}:`,
error instanceof Error ? error.message : String(error),
);
}
}
}
// ---------------------------------------------------------------------------
// Schedule: every 5 minutes
// ---------------------------------------------------------------------------
cron.schedule('*/5 * * * *', async () => {
console.log(`[${new Date().toISOString()}] Running epoch creation...`);
try {
await runEpochCreation();
} catch (error) {
console.error('Epoch creation failed:', error);
}
});
console.log('Elydora epoch scheduler started (every 5 minutes)');
// Graceful shutdown
async function shutdown() {
console.log('Shutting down scheduler...');
await cache.close();
await queue.close();
await db.close();
process.exit(0);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);
@@ -0,0 +1,545 @@
/**
* Agent service — business logic for agent lifecycle management.
*
* Handles registration, lookup, freezing, and key revocation. All
* administrative mutations are recorded as admin_events for audit.
*/
import type {
Agent,
AgentKey,
RegisterAgentRequest,
RegisterAgentResponse,
GetAgentResponse,
ListAgentsResponse,
AdminAction,
} from '../shared/index.js';
import { generateUUIDv7 } from '../utils/uuid.js';
import { base64urlDecode } from '../utils/crypto.js';
import { AppError } from '../middleware/error-handler.js';
import type { Database, PreparedStatement } from '../adapters/interfaces.js';
// ---------------------------------------------------------------------------
// Register agent
// ---------------------------------------------------------------------------
export async function registerAgent(
db: Database,
body: RegisterAgentRequest,
orgId: string,
actor: string,
): Promise<RegisterAgentResponse> {
const now = Date.now();
// Check if agent already exists
const existing = await db
.prepare('SELECT agent_id FROM agents WHERE agent_id = ?')
.bind(body.agent_id)
.first<{ agent_id: string }>();
if (existing) {
throw new AppError(409, 'VALIDATION_ERROR', { key: 'agent.alreadyExists', params: { id: body.agent_id } });
}
// Validate at least one key is provided
if (!body.keys || body.keys.length === 0) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.atLeastOneKey' });
}
// Validate each public key is a valid 32-byte Ed25519 key
for (const k of body.keys) {
if (k.algorithm !== 'ed25519') {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.unsupportedAlgorithm', params: { algorithm: k.algorithm } });
}
try {
const keyBytes = base64urlDecode(k.public_key);
if (keyBytes.length !== 32) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.invalidPublicKeyLength', params: { kid: k.kid, expected: 32, actual: keyBytes.length } });
}
} catch (e) {
if (e instanceof AppError) throw e;
throw new AppError(400, 'VALIDATION_ERROR', { key: 'agent.invalidPublicKeyEncoding', params: { kid: k.kid } });
}
}
// Build the agent record
const agent: Agent = {
agent_id: body.agent_id,
org_id: orgId,
display_name: body.display_name ?? body.agent_id,
responsible_entity: body.responsible_entity ?? '',
integration_type: body.integration_type ?? 'sdk',
status: 'active',
created_at: now,
updated_at: now,
};
// Build key records
const keys: AgentKey[] = body.keys.map((k) => ({
kid: k.kid,
agent_id: body.agent_id,
public_key: k.public_key,
algorithm: k.algorithm,
status: 'active' as const,
created_at: now,
retired_at: null,
}));
// Use a batch to insert atomically
const statements: PreparedStatement[] = [];
// Insert agent
statements.push(
db
.prepare(
`INSERT INTO agents (agent_id, org_id, display_name, responsible_entity, integration_type, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
agent.agent_id,
agent.org_id,
agent.display_name,
agent.responsible_entity,
agent.integration_type,
agent.status,
agent.created_at,
agent.updated_at,
),
);
// Insert keys
for (const key of keys) {
statements.push(
db
.prepare(
`INSERT INTO agent_keys (kid, agent_id, public_key, algorithm, status, created_at, retired_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
key.kid,
key.agent_id,
key.public_key,
key.algorithm,
key.status,
key.created_at,
key.retired_at,
),
);
}
// Insert admin event
const eventId = generateUUIDv7();
const action: AdminAction = 'agent.register';
statements.push(
db
.prepare(
`INSERT INTO admin_events (event_id, org_id, actor, action, target_type, target_id, details, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
eventId,
orgId,
actor,
action,
'agent',
body.agent_id,
JSON.stringify({ display_name: agent.display_name, integration_type: agent.integration_type, key_count: keys.length }),
now,
),
);
await db.batch(statements);
return { agent, keys };
}
// ---------------------------------------------------------------------------
// List agents
// ---------------------------------------------------------------------------
export async function listAgents(
db: Database,
orgId: string,
): Promise<ListAgentsResponse> {
const result = await db
.prepare('SELECT * FROM agents WHERE org_id = ? ORDER BY created_at DESC')
.bind(orgId)
.all<Agent>();
return { agents: result.results ?? [] };
}
// ---------------------------------------------------------------------------
// Get agent
// ---------------------------------------------------------------------------
export async function getAgent(
db: Database,
agentId: string,
orgId: string,
): Promise<GetAgentResponse> {
const agentRow = await db
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
.bind(agentId, orgId)
.first<Agent>();
if (!agentRow) {
throw new AppError(404, 'NOT_FOUND', { key: 'agent.notFound', params: { id: agentId } });
}
const keysResult = await db
.prepare('SELECT * FROM agent_keys WHERE agent_id = ?')
.bind(agentId)
.all<AgentKey>();
const keys = keysResult.results ?? [];
return { agent: agentRow, keys };
}
// ---------------------------------------------------------------------------
// Update agent integration type
// ---------------------------------------------------------------------------
const VALID_INTEGRATION_TYPES = [
'sdk', 'claudecode', 'cursor', 'gemini', 'kirocli', 'kiroide',
'opencode', 'copilot', 'letta', 'codex', 'kimi',
'enterprise', 'gui', 'other',
] as const;
export async function updateAgentIntegrationType(
db: Database,
agentId: string,
integrationValue: string,
orgId: string,
actor: string,
): Promise<{ agent: Agent }> {
const now = Date.now();
const agentRow = await db
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
.bind(agentId, orgId)
.first<Agent>();
if (!agentRow) {
throw new AppError(404, 'NOT_FOUND', { key: 'agent.notFound', params: { id: agentId } });
}
if (!(VALID_INTEGRATION_TYPES as readonly string[]).includes(integrationValue)) {
throw new AppError(
400,
'VALIDATION_ERROR',
{ key: 'agent.invalidIntegrationType', params: { value: integrationValue, valid: VALID_INTEGRATION_TYPES.join(', ') } },
);
}
const statements: PreparedStatement[] = [];
statements.push(
db
.prepare('UPDATE agents SET integration_type = ?, updated_at = ? WHERE agent_id = ?')
.bind(integrationValue, now, agentId),
);
const eventId = generateUUIDv7();
const action: AdminAction = 'agent.update';
statements.push(
db
.prepare(
`INSERT INTO admin_events (event_id, org_id, actor, action, target_type, target_id, details, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
eventId,
orgId,
actor,
action,
'agent',
agentId,
JSON.stringify({ integration_type: integrationValue, previous_integration_type: agentRow.integration_type }),
now,
),
);
await db.batch(statements);
const updatedAgent: Agent = {
...agentRow,
integration_type: integrationValue,
updated_at: now,
};
return { agent: updatedAgent };
}
// ---------------------------------------------------------------------------
// Freeze agent
// ---------------------------------------------------------------------------
export async function freezeAgent(
db: Database,
agentId: string,
reason: string,
orgId: string,
actor: string,
): Promise<{ agent: Agent }> {
const now = Date.now();
// Look up the agent
const agentRow = await db
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
.bind(agentId, orgId)
.first<Agent>();
if (!agentRow) {
throw new AppError(404, 'NOT_FOUND', { key: 'agent.notFound', params: { id: agentId } });
}
if (agentRow.status === 'frozen') {
throw new AppError(409, 'VALIDATION_ERROR', { key: 'agent.alreadyFrozen', params: { id: agentId } });
}
if (agentRow.status === 'revoked') {
throw new AppError(409, 'VALIDATION_ERROR', { key: 'agent.permanentlyRevoked', params: { id: agentId } });
}
const statements: PreparedStatement[] = [];
// Update agent status
statements.push(
db
.prepare('UPDATE agents SET status = ?, updated_at = ? WHERE agent_id = ?')
.bind('frozen', now, agentId),
);
// Log admin event
const eventId = generateUUIDv7();
const action: AdminAction = 'agent.freeze';
statements.push(
db
.prepare(
`INSERT INTO admin_events (event_id, org_id, actor, action, target_type, target_id, details, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(eventId, orgId, actor, action, 'agent', agentId, JSON.stringify({ reason }), now),
);
await db.batch(statements);
const updatedAgent: Agent = {
...agentRow,
status: 'frozen',
updated_at: now,
};
return { agent: updatedAgent };
}
// ---------------------------------------------------------------------------
// Unfreeze agent
// ---------------------------------------------------------------------------
export async function unfreezeAgent(
db: Database,
agentId: string,
reason: string,
orgId: string,
actor: string,
): Promise<{ agent: Agent }> {
const now = Date.now();
const agentRow = await db
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
.bind(agentId, orgId)
.first<Agent>();
if (!agentRow) {
throw new AppError(404, 'NOT_FOUND', { key: 'agent.notFound', params: { id: agentId } });
}
if (agentRow.status === 'active') {
throw new AppError(409, 'VALIDATION_ERROR', { key: 'agent.notFrozen', params: { id: agentId } });
}
if (agentRow.status === 'revoked') {
throw new AppError(409, 'VALIDATION_ERROR', { key: 'agent.permanentlyRevokedCannotUnfreeze', params: { id: agentId } });
}
const statements: PreparedStatement[] = [];
statements.push(
db
.prepare('UPDATE agents SET status = ?, updated_at = ? WHERE agent_id = ?')
.bind('active', now, agentId),
);
const eventId = generateUUIDv7();
const action: AdminAction = 'agent.unfreeze';
statements.push(
db
.prepare(
`INSERT INTO admin_events (event_id, org_id, actor, action, target_type, target_id, details, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(eventId, orgId, actor, action, 'agent', agentId, JSON.stringify({ reason }), now),
);
await db.batch(statements);
const updatedAgent: Agent = {
...agentRow,
status: 'active',
updated_at: now,
};
return { agent: updatedAgent };
}
// ---------------------------------------------------------------------------
// Delete agent
// ---------------------------------------------------------------------------
export async function deleteAgent(
db: Database,
agentId: string,
orgId: string,
actor: string,
): Promise<void> {
const now = Date.now();
// Verify the agent belongs to this org
const agentRow = await db
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
.bind(agentId, orgId)
.first<Agent>();
if (!agentRow) {
throw new AppError(404, 'NOT_FOUND', { key: 'agent.notFound', params: { id: agentId } });
}
const statements: PreparedStatement[] = [];
// Delete receipts for this agent's operations (receipts FK → operations)
statements.push(
db.prepare(
'DELETE FROM receipts WHERE operation_id IN (SELECT operation_id FROM operations WHERE agent_id = ?)',
).bind(agentId),
);
// Delete operations (operations FK → agents)
statements.push(
db.prepare('DELETE FROM operations WHERE agent_id = ?').bind(agentId),
);
// Delete associated keys (agent_keys FK → agents)
statements.push(
db.prepare('DELETE FROM agent_keys WHERE agent_id = ?').bind(agentId),
);
// Delete the agent
statements.push(
db.prepare('DELETE FROM agents WHERE agent_id = ? AND org_id = ?').bind(agentId, orgId),
);
// Log admin event
const eventId = generateUUIDv7();
const action: AdminAction = 'agent.delete';
statements.push(
db
.prepare(
`INSERT INTO admin_events (event_id, org_id, actor, action, target_type, target_id, details, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
eventId,
orgId,
actor,
action,
'agent',
agentId,
JSON.stringify({ display_name: agentRow.display_name }),
now,
),
);
await db.batch(statements);
}
// ---------------------------------------------------------------------------
// Revoke key
// ---------------------------------------------------------------------------
export async function revokeKey(
db: Database,
agentId: string,
kid: string,
reason: string,
orgId: string,
actor: string,
): Promise<{ key: AgentKey }> {
const now = Date.now();
// Verify the agent belongs to this org
const agentRow = await db
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
.bind(agentId, orgId)
.first<Agent>();
if (!agentRow) {
throw new AppError(404, 'NOT_FOUND', { key: 'agent.notFound', params: { id: agentId } });
}
// Look up the key
const keyRow = await db
.prepare('SELECT * FROM agent_keys WHERE kid = ? AND agent_id = ?')
.bind(kid, agentId)
.first<AgentKey>();
if (!keyRow) {
throw new AppError(404, 'NOT_FOUND', { key: 'agent.keyNotFound', params: { kid, id: agentId } });
}
if (keyRow.status === 'revoked') {
throw new AppError(409, 'VALIDATION_ERROR', { key: 'agent.keyAlreadyRevoked', params: { kid } });
}
const statements: PreparedStatement[] = [];
// Revoke the key
statements.push(
db
.prepare('UPDATE agent_keys SET status = ?, retired_at = ? WHERE kid = ?')
.bind('revoked', now, kid),
);
// Log admin event
const eventId = generateUUIDv7();
const action: AdminAction = 'key.revoke';
statements.push(
db
.prepare(
`INSERT INTO admin_events (event_id, org_id, actor, action, target_type, target_id, details, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
eventId,
orgId,
actor,
action,
'agent_key',
kid,
JSON.stringify({ agent_id: agentId, reason }),
now,
),
);
await db.batch(statements);
const updatedKey: AgentKey = {
...keyRow,
status: 'revoked',
retired_at: now,
};
return { key: updatedKey };
}
@@ -0,0 +1,123 @@
/**
* Audit service — business logic for querying the tamper-evident audit log.
*
* Builds dynamic SQL queries with optional filters (org_id, agent_id,
* operation_type, time range) and supports cursor-based pagination for
* efficient traversal of large result sets.
*/
import type { Operation, AuditQueryRequest, AuditQueryResponse } from '../shared/index.js';
import { DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT } from '../shared/index.js';
import { decodeCursor, encodeCursor } from '../utils/pagination.js';
import { AppError } from '../middleware/error-handler.js';
import type { Database } from '../adapters/interfaces.js';
// ---------------------------------------------------------------------------
// Query audit log
// ---------------------------------------------------------------------------
export async function queryAudit(
db: Database,
params: AuditQueryRequest,
orgId: string,
): Promise<AuditQueryResponse> {
// Validate and clamp limit
const limit = Math.min(
Math.max(params.limit ?? DEFAULT_QUERY_LIMIT, 1),
MAX_QUERY_LIMIT,
);
// Build WHERE clauses and bind values
const conditions: string[] = [];
const bindings: (string | number)[] = [];
// Always scope to the caller's org
conditions.push('org_id = ?');
bindings.push(orgId);
// Optional agent filter (further restrict if the request specifies an org_id matching theirs)
if (params.agent_id) {
conditions.push('agent_id = ?');
bindings.push(params.agent_id);
}
if (params.operation_type) {
conditions.push('operation_type = ?');
bindings.push(params.operation_type);
}
if (params.start_time !== undefined && params.start_time !== null) {
if (typeof params.start_time !== 'number' || params.start_time < 0) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'audit.invalidStartTime' });
}
conditions.push('created_at >= ?');
bindings.push(params.start_time);
}
if (params.end_time !== undefined && params.end_time !== null) {
if (typeof params.end_time !== 'number' || params.end_time < 0) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'audit.invalidEndTime' });
}
conditions.push('created_at <= ?');
bindings.push(params.end_time);
}
// Cursor-based pagination (keyset pagination)
let cursorPayload = params.cursor ? decodeCursor(params.cursor) : null;
if (params.cursor && !cursorPayload) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'audit.invalidCursor' });
}
if (cursorPayload) {
// For descending order: fetch rows where (created_at, operation_id) < cursor
conditions.push('(created_at < ? OR (created_at = ? AND operation_id < ?))');
bindings.push(cursorPayload.created_at, cursorPayload.created_at, cursorPayload.id);
}
const whereClause = conditions.length > 0 ? 'WHERE ' + conditions.join(' AND ') : '';
// Get total count (without cursor filter for accurate total)
const countConditions = conditions.filter(
(_, i) => !cursorPayload || i < conditions.length - 1,
);
const countBindings = cursorPayload ? bindings.slice(0, -3) : [...bindings];
const countWhereClause =
countConditions.length > 0 ? 'WHERE ' + countConditions.join(' AND ') : '';
const countQuery = `SELECT COUNT(*) as total FROM operations ${countWhereClause}`;
const countStmt = db.prepare(countQuery);
const boundCountStmt =
countBindings.length > 0 ? countStmt.bind(...countBindings) : countStmt;
const countResult = await boundCountStmt.first<{ total: number }>();
const totalCount = countResult?.total ?? 0;
// Fetch the page of results (reverse chronological)
// Request limit+1 to detect if there are more pages
const dataQuery = `SELECT * FROM operations ${whereClause} ORDER BY created_at DESC, operation_id DESC LIMIT ?`;
const dataBindings = [...bindings, limit + 1];
const dataStmt = db.prepare(dataQuery);
const boundDataStmt = dataStmt.bind(...dataBindings);
const dataResult = await boundDataStmt.all<Operation>();
const rows = dataResult.results ?? [];
// Determine next cursor
const hasMore = rows.length > limit;
const operations = hasMore ? rows.slice(0, limit) : rows;
let nextCursor: string | undefined;
if (hasMore && operations.length > 0) {
const lastItem = operations[operations.length - 1]!;
nextCursor = encodeCursor({
created_at: lastItem.created_at,
id: lastItem.operation_id,
});
}
return {
operations,
cursor: nextCursor,
total_count: totalCount,
};
}
@@ -0,0 +1,323 @@
/**
* Auth service — password hashing, JWT issuance, user registration, and login.
*
* Uses PBKDF2-SHA256 via Web Crypto API for password hashing and
* HMAC-SHA256 for JWT issuance (matching the existing auth middleware).
*/
import { base64urlEncode } from '../utils/crypto.js';
import { generateUUIDv7 } from '../utils/uuid.js';
import { AppError } from '../middleware/error-handler.js';
import { DEFAULT_JWT_TTL_SECONDS } from '../shared/index.js';
import type { RbacRole } from '../shared/index.js';
import type { Database } from '../adapters/interfaces.js';
// ---------------------------------------------------------------------------
// Row types (query results)
// ---------------------------------------------------------------------------
export interface UserRow {
readonly user_id: string;
readonly org_id: string;
readonly email: string;
readonly display_name: string;
readonly role: RbacRole;
readonly status: 'active' | 'suspended';
readonly created_at: number;
readonly updated_at: number;
}
export interface OrgRow {
readonly org_id: string;
readonly name: string;
readonly created_at: number;
readonly updated_at: number;
}
// ---------------------------------------------------------------------------
// JWT payload
// ---------------------------------------------------------------------------
interface JWTPayload {
sub: string;
org_id: string;
role: RbacRole;
iat: number;
exp: number;
}
// ---------------------------------------------------------------------------
// Password hashing (PBKDF2-SHA256 via Web Crypto)
// Format: pbkdf2:100000:salt_hex:hash_hex
// ---------------------------------------------------------------------------
export async function hashPassword(password: string): Promise<string> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(password),
'PBKDF2',
false,
['deriveBits'],
);
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt, iterations: 100000, hash: 'SHA-256' },
keyMaterial,
256,
);
const saltHex = Array.from(salt)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
const hashHex = Array.from(new Uint8Array(bits))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return `pbkdf2:100000:${saltHex}:${hashHex}`;
}
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const parts = stored.split(':');
if (parts.length !== 4 || parts[0] !== 'pbkdf2') return false;
const iterations = parseInt(parts[1]!, 10);
const saltHex = parts[2]!;
const storedHashHex = parts[3]!;
const salt = new Uint8Array(saltHex.match(/.{2}/g)!.map((b) => parseInt(b, 16)));
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(password),
'PBKDF2',
false,
['deriveBits'],
);
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
keyMaterial,
256,
);
const hashHex = Array.from(new Uint8Array(bits))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
// Constant-time comparison
if (hashHex.length !== storedHashHex.length) return false;
let result = 0;
for (let i = 0; i < hashHex.length; i++) {
result |= hashHex.charCodeAt(i) ^ storedHashHex.charCodeAt(i);
}
return result === 0;
}
// ---------------------------------------------------------------------------
// JWT issuance (HMAC-SHA256, matching existing auth middleware verification)
// ---------------------------------------------------------------------------
export async function issueJWT(payload: JWTPayload, secret: string): Promise<string> {
const encoder = new TextEncoder();
const header = { alg: 'HS256', typ: 'JWT' };
const headerB64 = base64urlEncode(encoder.encode(JSON.stringify(header)));
const payloadB64 = base64urlEncode(encoder.encode(JSON.stringify(payload)));
const signingInput = `${headerB64}.${payloadB64}`;
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(signingInput));
const signatureB64 = base64urlEncode(new Uint8Array(signature));
return `${headerB64}.${payloadB64}.${signatureB64}`;
}
// ---------------------------------------------------------------------------
// Issue API token with custom TTL
// ---------------------------------------------------------------------------
/** Maximum API token lifetime: 365 days. */
const MAX_API_TOKEN_TTL = 365 * 24 * 60 * 60;
export async function issueApiToken(
sub: string,
orgId: string,
role: RbacRole,
jwtSecret: string,
ttlSeconds: number | null,
): Promise<{ token: string; expires_at: number }> {
const now = Math.floor(Date.now() / 1000);
// Default to 90 days if no TTL specified; cap at 365 days
const effectiveTtl = ttlSeconds === null ? 90 * 24 * 60 * 60 : Math.min(ttlSeconds, MAX_API_TOKEN_TTL);
if (effectiveTtl <= 0) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'auth.ttlMustBePositive' });
}
const exp = now + effectiveTtl;
const expiresAt = exp;
const token = await issueJWT(
{ sub, org_id: orgId, role, iat: now, exp },
jwtSecret,
);
return { token, expires_at: expiresAt };
}
// ---------------------------------------------------------------------------
// Register user + org
// ---------------------------------------------------------------------------
export async function registerUser(
db: Database,
jwtSecret: string,
email: string,
password: string,
displayName: string,
orgName: string,
): Promise<{ user: UserRow; organization: OrgRow; token: string }> {
// Validate
if (!email || !password || password.length < 8 || password.length > 128) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'auth.emailPasswordRequired' });
}
// Check email uniqueness
const existing = await db
.prepare('SELECT user_id FROM users WHERE email = ?')
.bind(email)
.first();
if (existing) {
throw new AppError(409, 'VALIDATION_ERROR', { key: 'auth.emailAlreadyRegistered' });
}
const now = Date.now();
const orgId = generateUUIDv7();
const userId = generateUUIDv7();
const hash = await hashPassword(password);
const resolvedDisplayName = displayName || email.split('@')[0]!;
const resolvedOrgName = orgName || `${resolvedDisplayName}'s Organization`;
// Create org + user atomically in a single transaction
await db.batch([
db
.prepare(
'INSERT INTO organizations (org_id, name, created_at, updated_at) VALUES (?, ?, ?, ?)',
)
.bind(orgId, resolvedOrgName, now, now),
db
.prepare(
'INSERT INTO users (user_id, org_id, email, password_hash, display_name, role, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
)
.bind(userId, orgId, email, hash, resolvedDisplayName, 'org_owner', 'active', now, now),
]);
const token = await issueJWT(
{
sub: userId,
org_id: orgId,
role: 'org_owner',
iat: Math.floor(now / 1000),
exp: Math.floor(now / 1000) + DEFAULT_JWT_TTL_SECONDS,
},
jwtSecret,
);
return {
user: {
user_id: userId,
org_id: orgId,
email,
display_name: resolvedDisplayName,
role: 'org_owner' as RbacRole,
status: 'active',
created_at: now,
updated_at: now,
},
organization: {
org_id: orgId,
name: resolvedOrgName,
created_at: now,
updated_at: now,
},
token,
};
}
// ---------------------------------------------------------------------------
// Login user
// ---------------------------------------------------------------------------
export async function loginUser(
db: Database,
jwtSecret: string,
email: string,
password: string,
): Promise<{ user: UserRow; token: string }> {
const row = await db
.prepare(
'SELECT user_id, org_id, email, password_hash, display_name, role, status, created_at, updated_at FROM users WHERE email = ?',
)
.bind(email)
.first<UserRow & { password_hash: string }>();
if (!row) {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.invalidCredentials' });
}
if (row.status !== 'active') {
throw new AppError(403, 'FORBIDDEN', { key: 'auth.accountSuspended' });
}
const valid = await verifyPassword(password, row.password_hash);
if (!valid) {
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.invalidCredentials' });
}
const now = Date.now();
const token = await issueJWT(
{
sub: row.user_id,
org_id: row.org_id,
role: row.role as RbacRole,
iat: Math.floor(now / 1000),
exp: Math.floor(now / 1000) + DEFAULT_JWT_TTL_SECONDS,
},
jwtSecret,
);
return {
user: {
user_id: row.user_id,
org_id: row.org_id,
email: row.email,
display_name: row.display_name,
role: row.role as RbacRole,
status: row.status,
created_at: row.created_at,
updated_at: row.updated_at,
},
token,
};
}
// ---------------------------------------------------------------------------
// Get current user profile
// ---------------------------------------------------------------------------
export async function getMe(db: Database, userId: string): Promise<UserRow> {
const row = await db
.prepare(
'SELECT user_id, org_id, email, display_name, role, status, created_at, updated_at FROM users WHERE user_id = ?',
)
.bind(userId)
.first<UserRow>();
if (!row) {
throw new AppError(404, 'NOT_FOUND', { key: 'auth.userNotFound' });
}
return row;
}
@@ -0,0 +1,175 @@
/**
* Epoch service — business logic for retrieving epoch root records.
*
* Epochs are periodic Merkle-tree rollups that anchor a set of operations
* to a single root hash. Optionally, the epoch may carry a TSA
* (Timestamping Authority) anchor that binds the root to an external
* trusted timestamp for non-repudiation.
*/
import type { Epoch, EER, GetEpochResponse, ListEpochsResponse } from '../shared/index.js';
import { AppError } from '../middleware/error-handler.js';
import { buildMerkleTree } from '../utils/merkle.js';
import { generateUUIDv7 } from '../utils/uuid.js';
import { signEd25519, jcsCanonicalise } from '../utils/crypto.js';
import type { Database, ObjectStore } from '../adapters/interfaces.js';
// ---------------------------------------------------------------------------
// List epochs
// ---------------------------------------------------------------------------
export async function listEpochs(
db: Database,
orgId: string,
): Promise<ListEpochsResponse> {
const { results } = await db
.prepare('SELECT * FROM epochs WHERE org_id = ? ORDER BY created_at DESC')
.bind(orgId)
.all<Epoch>();
return { epochs: results ?? [] };
}
// ---------------------------------------------------------------------------
// Get epoch
// ---------------------------------------------------------------------------
export async function getEpoch(
db: Database,
r2: ObjectStore,
epochId: string,
orgId: string,
): Promise<GetEpochResponse> {
const epoch = await db
.prepare('SELECT * FROM epochs WHERE epoch_id = ? AND org_id = ?')
.bind(epochId, orgId)
.first<Epoch>();
if (!epoch) {
throw new AppError(404, 'NOT_FOUND', { key: 'epoch.notFound', params: { id: epochId } });
}
// Attempt to load TSA anchor from R2 (stored alongside the epoch record)
let anchor: { tsa_token?: string; tsa_url?: string; anchored_at?: number } | undefined;
try {
const tsaKey = `${epoch.r2_epoch_key}/tsa-anchor`;
const tsaObject = await r2.get(tsaKey);
if (tsaObject) {
const tsaData = JSON.parse(await tsaObject.text()) as {
tsa_token?: string;
tsa_url?: string;
anchored_at?: number;
};
if (tsaData.tsa_token) {
anchor = {
tsa_token: tsaData.tsa_token,
tsa_url: tsaData.tsa_url,
anchored_at: tsaData.anchored_at,
};
}
}
} catch {
// TSA anchor is optional; if it doesn't exist or fails to parse, omit it
}
return {
epoch,
anchor,
};
}
// ---------------------------------------------------------------------------
// Create epoch
// ---------------------------------------------------------------------------
/**
* Create a new epoch by building a Merkle tree over operations in a time
* window, signing the Epoch Record, and persisting to D1 + R2.
*
* Returns null if the epoch already exists, or if no operations fall within
* the given time range.
*/
export async function createEpoch(
db: Database,
r2: ObjectStore,
signingKey: string,
orgId: string,
startTime: number,
endTime: number,
): Promise<Epoch | null> {
// 1. Idempotency check — skip if epoch already exists for this window
const existing = await db
.prepare(
'SELECT epoch_id FROM epochs WHERE org_id = ? AND start_time = ? AND end_time = ?',
)
.bind(orgId, startTime, endTime)
.first<{ epoch_id: string }>();
if (existing) return null;
// 2. Query operations in the time window
const { results: ops } = await db
.prepare(
'SELECT operation_id, chain_hash FROM operations WHERE org_id = ? AND created_at >= ? AND created_at < ? ORDER BY chain_hash ASC',
)
.bind(orgId, startTime, endTime)
.all<{ operation_id: string; chain_hash: string }>();
if (!ops || ops.length === 0) return null;
// 3. Build Merkle tree
const chainHashes = ops.map((op) => op.chain_hash);
const operationIds = ops.map((op) => op.operation_id);
const tree = await buildMerkleTree(chainHashes, operationIds);
// 4. Generate epoch ID
const epochId = generateUUIDv7();
// 5. Create and sign the EER
const eer: { -readonly [K in keyof EER]: EER[K] } = {
epoch_id: epochId,
org_id: orgId,
start_time: startTime,
end_time: endTime,
leaf_count: tree.leaves.length,
root_hash: tree.root,
hash_alg: 'sha256',
signature_by_elydora: '',
};
const canonical = jcsCanonicalise(eer);
eer.signature_by_elydora = await signEd25519(
signingKey,
new TextEncoder().encode(canonical),
);
// 6. Store in R2
const r2Key = `epochs/${orgId}/${epochId}`;
const r2Data = {
eer: eer as EER,
merkle: { leafOps: tree.leafOps, layers: tree.layers },
};
await r2.put(r2Key, JSON.stringify(r2Data), {
httpMetadata: { contentType: 'application/json' },
});
// 7. Insert epoch record in D1
const now = Date.now();
await db
.prepare(
'INSERT INTO epochs (epoch_id, org_id, start_time, end_time, root_hash, leaf_count, r2_epoch_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
)
.bind(epochId, orgId, startTime, endTime, tree.root, tree.leaves.length, r2Key, now)
.run();
return {
epoch_id: epochId,
org_id: orgId,
start_time: startTime,
end_time: endTime,
root_hash: tree.root,
leaf_count: tree.leaves.length,
r2_epoch_key: r2Key,
created_at: now,
};
}
@@ -0,0 +1,163 @@
/**
* Export service — business logic for compliance export job management.
*
* Exports are asynchronous jobs that package operations matching specified
* criteria into a downloadable bundle (JSON or PDF). The job is enqueued
* via a message queue and processed by the queue consumer. Once complete,
* the result is stored in the object store and the database record is
* updated with a reference to the object key.
*/
import type { Export, CreateExportRequest, CreateExportResponse, GetExportResponse, ListExportsResponse } from '../shared/index.js';
import { generateUUIDv7 } from '../utils/uuid.js';
import { AppError } from '../middleware/error-handler.js';
import type { Database, MessageQueue, ObjectStore } from '../adapters/interfaces.js';
// ---------------------------------------------------------------------------
// Create export
// ---------------------------------------------------------------------------
export async function createExport(
db: Database,
queue: MessageQueue,
body: CreateExportRequest,
orgId: string,
actor: string,
): Promise<CreateExportResponse> {
const now = Date.now();
// Validate time range
if (typeof body.start_time !== 'number' || body.start_time <= 0) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'export.invalidStartTime' });
}
if (typeof body.end_time !== 'number' || body.end_time <= 0) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'export.invalidEndTime' });
}
if (body.start_time >= body.end_time) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'export.startBeforeEnd' });
}
// Validate format
if (body.format !== 'json' && body.format !== 'pdf') {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'export.invalidFormat' });
}
const exportId = generateUUIDv7();
const queryParams = JSON.stringify({
start_time: body.start_time,
end_time: body.end_time,
agent_id: body.agent_id ?? null,
operation_type: body.operation_type ?? null,
format: body.format,
});
const exportRecord: Export = {
export_id: exportId,
org_id: orgId,
status: 'queued',
query_params: queryParams,
r2_export_key: null,
created_at: now,
completed_at: null,
};
// Insert export record
await db
.prepare(
`INSERT INTO exports (export_id, org_id, status, query_params, r2_export_key, created_at, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
exportRecord.export_id,
exportRecord.org_id,
exportRecord.status,
exportRecord.query_params,
exportRecord.r2_export_key,
exportRecord.created_at,
exportRecord.completed_at,
)
.run();
// Log admin event
const eventId = generateUUIDv7();
await db
.prepare(
`INSERT INTO admin_events (event_id, org_id, actor, action, target_type, target_id, details, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
eventId,
orgId,
actor,
'export.create',
'export',
exportId,
queryParams,
now,
)
.run();
// Enqueue the export job
await queue.send({
type: 'export',
export_id: exportId,
org_id: orgId,
query_params: queryParams,
});
return { export: exportRecord };
}
// ---------------------------------------------------------------------------
// List exports
// ---------------------------------------------------------------------------
export async function listExports(
db: Database,
orgId: string,
): Promise<ListExportsResponse> {
const { results } = await db
.prepare('SELECT * FROM exports WHERE org_id = ? ORDER BY created_at DESC')
.bind(orgId)
.all<Export>();
return { exports: results ?? [] };
}
// ---------------------------------------------------------------------------
// Get export
// ---------------------------------------------------------------------------
export async function getExport(
db: Database,
r2: ObjectStore,
exportId: string,
orgId: string,
): Promise<GetExportResponse> {
const exportRecord = await db
.prepare('SELECT * FROM exports WHERE export_id = ? AND org_id = ?')
.bind(exportId, orgId)
.first<Export>();
if (!exportRecord) {
throw new AppError(404, 'NOT_FOUND', { key: 'export.notFound', params: { id: exportId } });
}
let downloadUrl: string | undefined;
// If the export is complete and has an object store key, provide a download path.
// The client can use a dedicated download endpoint to stream the file.
if (exportRecord.status === 'done' && exportRecord.r2_export_key) {
// Verify the object actually exists in R2
const head = await r2.head(exportRecord.r2_export_key);
if (head) {
// Construct a download path that the client can use
downloadUrl = `/v1/exports/${exportId}/download`;
}
}
return {
export: exportRecord,
download_url: downloadUrl,
};
}
@@ -0,0 +1,633 @@
/**
* Operation service — business logic for EOR submission, retrieval, and verification.
*
* This is the core of the Elydora protocol implementation. It performs:
* 1. EOR field validation
* 2. Agent and key status checks
* 3. Ed25519 signature verification
* 4. Hash chain validation and computation
* 5. Persistence to D1 and R2
* 6. Queue enqueue for async processing
* 7. EAR (receipt) generation with server signature
*/
import type {
EOR,
EAR,
Agent,
AgentKey,
Operation,
Receipt,
GetOperationResponse,
VerifyOperationResponse,
} from '../shared/index.js';
import {
MAX_PAYLOAD_SIZE,
MAX_TTL_MS,
MIN_TTL_MS,
MAX_NONCE_LENGTH,
} from '../shared/index.js';
import {
base64urlDecode,
verifyEd25519Signature,
jcsCanonicalise,
computeChainHash,
computeReceiptHash,
signEd25519,
} from '../utils/crypto.js';
import { generateUUIDv7 } from '../utils/uuid.js';
import { AppError } from '../middleware/error-handler.js';
import type { Database, ObjectStore, Cache, MessageQueue, PreparedStatement } from '../adapters/interfaces.js';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** The genesis chain hash used for an agent's first operation. */
const GENESIS_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
/** Elydora server key ID used in receipts. */
const ELYDORA_KID = 'elydora-server-key-v1';
// ---------------------------------------------------------------------------
// Submit operation
// ---------------------------------------------------------------------------
export async function submitOperation(
db: Database,
r2: ObjectStore,
kv: Cache,
queue: MessageQueue,
eor: EOR,
signingKey: string,
): Promise<{ receipt: EAR }> {
const receivedAt = Date.now();
// ------------------------------------------------------------------
// Step 1: Validate EOR fields
// ------------------------------------------------------------------
validateEORFields(eor, receivedAt);
// ------------------------------------------------------------------
// Step 2: Replay detection via nonce in KV
// ------------------------------------------------------------------
const nonceKey = `nonce:${eor.org_id}:${eor.nonce}`;
const existingNonce = await kv.get(nonceKey);
if (existingNonce) {
throw new AppError(400, 'REPLAY_DETECTED');
}
// Store nonce with TTL equal to the operation's TTL (in seconds)
const nonceTtlSeconds = Math.ceil(eor.ttl_ms / 1000);
await kv.put(nonceKey, '1', { expirationTtl: nonceTtlSeconds });
// ------------------------------------------------------------------
// Step 3: Look up agent and check status
// ------------------------------------------------------------------
const agent = await db
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
.bind(eor.agent_id, eor.org_id)
.first<Agent>();
if (!agent) {
throw new AppError(404, 'UNKNOWN_AGENT', { key: 'operation.agentNotRegistered', params: { id: eor.agent_id } });
}
if (agent.status === 'frozen') {
throw new AppError(403, 'AGENT_FROZEN');
}
if (agent.status === 'revoked') {
throw new AppError(403, 'AGENT_FROZEN', { key: 'operation.agentRevoked' });
}
// ------------------------------------------------------------------
// Step 4: Look up key and check status
// ------------------------------------------------------------------
const agentKey = await db
.prepare('SELECT * FROM agent_keys WHERE kid = ? AND agent_id = ?')
.bind(eor.agent_pubkey_kid, eor.agent_id)
.first<AgentKey>();
if (!agentKey) {
throw new AppError(400, 'INVALID_SIGNATURE', { key: 'operation.keyNotFoundForAgent', params: { kid: eor.agent_pubkey_kid, id: eor.agent_id } });
}
if (agentKey.status === 'revoked') {
throw new AppError(403, 'KEY_REVOKED');
}
if (agentKey.status === 'retired') {
throw new AppError(403, 'KEY_REVOKED', { key: 'operation.keyRetired' });
}
// ------------------------------------------------------------------
// Step 5: Verify Ed25519 signature
// ------------------------------------------------------------------
const signableEOR = buildSignableEOR(eor);
const canonicalData = jcsCanonicalise(signableEOR);
const dataBytes = new TextEncoder().encode(canonicalData);
const signatureBytes = base64urlDecode(eor.signature);
const publicKeyBytes = base64urlDecode(agentKey.public_key);
const signatureValid = await verifyEd25519Signature(publicKeyBytes, signatureBytes, dataBytes);
if (!signatureValid) {
throw new AppError(400, 'INVALID_SIGNATURE');
}
// ------------------------------------------------------------------
// Step 6: Verify prev_chain_hash against the latest chain_hash
// ------------------------------------------------------------------
const latestOp = await db
.prepare(
'SELECT chain_hash, seq_no FROM operations WHERE agent_id = ? ORDER BY seq_no DESC LIMIT 1',
)
.bind(eor.agent_id)
.first<{ chain_hash: string; seq_no: number }>();
const expectedPrevHash = latestOp ? latestOp.chain_hash : GENESIS_CHAIN_HASH;
const nextSeqNo = latestOp ? latestOp.seq_no + 1 : 1;
if (eor.prev_chain_hash !== expectedPrevHash) {
throw new AppError(400, 'PREV_HASH_MISMATCH', { key: 'operation.prevHashMismatch', params: { expected: expectedPrevHash, actual: eor.prev_chain_hash } });
}
// ------------------------------------------------------------------
// Step 7: Compute new chain_hash
// ------------------------------------------------------------------
const chainHash = await computeChainHash(
eor.prev_chain_hash,
eor.payload_hash,
eor.operation_id,
eor.issued_at,
);
// ------------------------------------------------------------------
// Step 8: Store operation in D1
// ------------------------------------------------------------------
const r2PayloadKey = `${eor.org_id}/${eor.agent_id}/${eor.operation_id}`;
const operation: Operation = {
operation_id: eor.operation_id,
org_id: eor.org_id,
agent_id: eor.agent_id,
seq_no: nextSeqNo,
operation_type: eor.operation_type,
issued_at: eor.issued_at,
ttl_ms: eor.ttl_ms,
nonce: eor.nonce,
subject: JSON.stringify(eor.subject),
action: JSON.stringify(eor.action),
payload_hash: eor.payload_hash,
prev_chain_hash: eor.prev_chain_hash,
chain_hash: chainHash,
agent_pubkey_kid: eor.agent_pubkey_kid,
signature: eor.signature,
r2_payload_key: r2PayloadKey,
created_at: receivedAt,
};
// ------------------------------------------------------------------
// Step 9: Store the full EOR envelope in R2
// ------------------------------------------------------------------
const eorJson = JSON.stringify(eor);
await r2.put(r2PayloadKey, eorJson, {
httpMetadata: { contentType: 'application/json' },
customMetadata: {
org_id: eor.org_id,
agent_id: eor.agent_id,
operation_type: eor.operation_type,
},
});
// ------------------------------------------------------------------
// Step 10: Enqueue for async processing
// ------------------------------------------------------------------
const queueMessageResult = await queue.send({
type: 'operation',
operation_id: eor.operation_id,
org_id: eor.org_id,
agent_id: eor.agent_id,
});
// D1 batch insert: operation + receipt
const receiptId = generateUUIDv7();
const queueMessageId = typeof queueMessageResult === 'object' && queueMessageResult !== null
? String((queueMessageResult as Record<string, unknown>).messageId ?? receiptId)
: receiptId;
// ------------------------------------------------------------------
// Step 11: Generate EAR receipt
// ------------------------------------------------------------------
const receiptFields = {
receipt_version: '1.0',
receipt_id: receiptId,
operation_id: eor.operation_id,
org_id: eor.org_id,
agent_id: eor.agent_id,
server_received_at: receivedAt,
seq_no: nextSeqNo,
chain_hash: chainHash,
queue_message_id: queueMessageId,
};
const receiptHash = await computeReceiptHash(receiptFields);
// Sign the receipt hash with the server key
const receiptDataToSign = new TextEncoder().encode(receiptHash);
const elydoraSignature = await signEd25519(signingKey, receiptDataToSign);
const ear: EAR = {
...receiptFields,
receipt_hash: receiptHash,
elydora_kid: ELYDORA_KID,
elydora_signature: elydoraSignature,
};
// Store receipt in R2
const r2ReceiptKey = `${eor.org_id}/${eor.agent_id}/receipts/${eor.operation_id}`;
await r2.put(r2ReceiptKey, JSON.stringify(ear), {
httpMetadata: { contentType: 'application/json' },
});
// ------------------------------------------------------------------
// Step 12: Persist operation and receipt to D1
// ------------------------------------------------------------------
const statements: PreparedStatement[] = [];
statements.push(
db
.prepare(
`INSERT INTO operations (operation_id, org_id, agent_id, seq_no, operation_type, issued_at, ttl_ms, nonce, subject, action, payload_hash, prev_chain_hash, chain_hash, agent_pubkey_kid, signature, r2_payload_key, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
operation.operation_id,
operation.org_id,
operation.agent_id,
operation.seq_no,
operation.operation_type,
operation.issued_at,
operation.ttl_ms,
operation.nonce,
operation.subject,
operation.action,
operation.payload_hash,
operation.prev_chain_hash,
operation.chain_hash,
operation.agent_pubkey_kid,
operation.signature,
operation.r2_payload_key,
operation.created_at,
),
);
statements.push(
db
.prepare(
`INSERT INTO receipts (receipt_id, operation_id, r2_receipt_key, created_at)
VALUES (?, ?, ?, ?)`,
)
.bind(receiptId, eor.operation_id, r2ReceiptKey, receivedAt),
);
await db.batch(statements);
return { receipt: ear };
}
// ---------------------------------------------------------------------------
// Get operation
// ---------------------------------------------------------------------------
export async function getOperation(
db: Database,
r2: ObjectStore,
operationId: string,
orgId: string,
): Promise<GetOperationResponse> {
const operation = await db
.prepare('SELECT * FROM operations WHERE operation_id = ? AND org_id = ?')
.bind(operationId, orgId)
.first<Operation>();
if (!operation) {
throw new AppError(404, 'NOT_FOUND', { key: 'operation.notFound', params: { id: operationId } });
}
const receipt = await db
.prepare('SELECT * FROM receipts WHERE operation_id = ?')
.bind(operationId)
.first<Receipt>();
// Fetch payload from R2
let payload: Record<string, unknown> | undefined;
if (operation.r2_payload_key) {
try {
const r2Object = await r2.get(operation.r2_payload_key);
if (r2Object) {
const eor = await r2Object.json<Record<string, unknown>>();
payload = (eor.payload as Record<string, unknown>) ?? undefined;
}
} catch {
// Payload fetch is best-effort — don't fail the request
}
}
return {
operation,
receipt: receipt ?? undefined,
payload,
};
}
// ---------------------------------------------------------------------------
// Verify operation
// ---------------------------------------------------------------------------
export async function verifyOperation(
db: Database,
r2: ObjectStore,
operationId: string,
orgId: string,
): Promise<VerifyOperationResponse> {
const errors: string[] = [];
let signatureCheck = false;
let chainCheck = false;
let receiptCheck = false;
// Load the operation
const operation = await db
.prepare('SELECT * FROM operations WHERE operation_id = ? AND org_id = ?')
.bind(operationId, orgId)
.first<Operation>();
if (!operation) {
throw new AppError(404, 'NOT_FOUND', { key: 'operation.notFound', params: { id: operationId } });
}
// Load the EOR from R2
if (!operation.r2_payload_key) {
errors.push('Operation has no R2 payload key.');
return { valid: false, checks: { signature: false, chain: false, receipt: false }, errors };
}
const eorObject = await r2.get(operation.r2_payload_key);
if (!eorObject) {
errors.push('EOR evidence not found in R2.');
return { valid: false, checks: { signature: false, chain: false, receipt: false }, errors };
}
let eor: EOR;
try {
eor = JSON.parse(await eorObject.text()) as EOR;
} catch {
errors.push('Failed to parse EOR evidence from R2.');
return { valid: false, checks: { signature: false, chain: false, receipt: false }, errors };
}
// ---- Signature verification ----
try {
const agentKey = await db
.prepare('SELECT * FROM agent_keys WHERE kid = ? AND agent_id = ?')
.bind(eor.agent_pubkey_kid, eor.agent_id)
.first<AgentKey>();
if (agentKey) {
const signableEOR = buildSignableEOR(eor);
const canonicalData = jcsCanonicalise(signableEOR);
const dataBytes = new TextEncoder().encode(canonicalData);
const signatureBytes = base64urlDecode(eor.signature);
const publicKeyBytes = base64urlDecode(agentKey.public_key);
signatureCheck = await verifyEd25519Signature(publicKeyBytes, signatureBytes, dataBytes);
if (!signatureCheck) {
errors.push('Ed25519 signature verification failed.');
}
} else {
errors.push(`Signing key "${eor.agent_pubkey_kid}" not found.`);
}
} catch (e) {
errors.push(`Signature check error: ${e instanceof Error ? e.message : String(e)}`);
}
// ---- Chain hash verification ----
try {
const expectedChainHash = await computeChainHash(
operation.prev_chain_hash,
operation.payload_hash,
operation.operation_id,
operation.issued_at,
);
chainCheck = expectedChainHash === operation.chain_hash;
if (!chainCheck) {
errors.push(`Chain hash mismatch: expected "${expectedChainHash}", stored "${operation.chain_hash}".`);
}
// Also verify the prev_chain_hash links correctly
if (operation.seq_no > 1) {
const prevOp = await db
.prepare('SELECT chain_hash FROM operations WHERE agent_id = ? AND seq_no = ?')
.bind(operation.agent_id, operation.seq_no - 1)
.first<{ chain_hash: string }>();
if (prevOp && prevOp.chain_hash !== operation.prev_chain_hash) {
chainCheck = false;
errors.push(`prev_chain_hash does not match previous operation's chain_hash.`);
}
}
} catch (e) {
errors.push(`Chain check error: ${e instanceof Error ? e.message : String(e)}`);
}
// ---- Receipt verification ----
try {
const receiptRow = await db
.prepare('SELECT * FROM receipts WHERE operation_id = ?')
.bind(operationId)
.first<Receipt>();
if (receiptRow) {
const receiptObject = await r2.get(receiptRow.r2_receipt_key);
if (receiptObject) {
const ear = JSON.parse(await receiptObject.text()) as EAR;
// Recompute receipt hash
const receiptFields = {
receipt_version: ear.receipt_version,
receipt_id: ear.receipt_id,
operation_id: ear.operation_id,
org_id: ear.org_id,
agent_id: ear.agent_id,
server_received_at: ear.server_received_at,
seq_no: ear.seq_no,
chain_hash: ear.chain_hash,
queue_message_id: ear.queue_message_id,
};
const expectedHash = await computeReceiptHash(receiptFields);
if (expectedHash === ear.receipt_hash) {
receiptCheck = true;
} else {
errors.push('Receipt hash does not match recomputed value.');
}
} else {
errors.push('Receipt evidence not found in R2.');
}
} else {
errors.push('No receipt found for this operation.');
}
} catch (e) {
errors.push(`Receipt check error: ${e instanceof Error ? e.message : String(e)}`);
}
// ---- Merkle inclusion verification ----
let merkleCheck: boolean | undefined;
// Find epoch by operation's created_at timestamp
const epochRecord = await db
.prepare('SELECT * FROM epochs WHERE org_id = ? AND start_time <= ? AND end_time > ?')
.bind(orgId, operation.created_at, operation.created_at)
.first<{ epoch_id: string; org_id: string; start_time: number; end_time: number; root_hash: string; leaf_count: number; r2_epoch_key: string; created_at: number }>();
if (epochRecord) {
try {
const epochObj = await r2.get(epochRecord.r2_epoch_key);
if (epochObj) {
const epochData = JSON.parse(await epochObj.text()) as {
merkle: { leafOps: string[]; layers: string[][] };
};
// Check if operation's chain_hash is in the leaf layer
const leaves = epochData.merkle.layers[0];
if (leaves && leaves.includes(operation.chain_hash)) {
// Verify the tree root matches the epoch's root_hash
const { getMerkleProof, verifyMerkleProof } = await import('../utils/merkle.js');
const tree = {
root: epochRecord.root_hash,
leaves,
leafOps: epochData.merkle.leafOps,
layers: epochData.merkle.layers,
};
const proof = getMerkleProof(tree, operation.chain_hash);
if (proof) {
merkleCheck = await verifyMerkleProof(proof);
if (!merkleCheck) {
errors.push('Merkle inclusion proof verification failed.');
}
} else {
merkleCheck = false;
errors.push('Operation chain_hash found in leaves but proof generation failed.');
}
} else {
merkleCheck = false;
errors.push('Operation chain_hash not found in epoch Merkle tree leaves.');
}
}
} catch {
// If R2 read fails, leave merkle as undefined (pending)
}
}
// If no epoch found, merkleCheck remains undefined (pending)
const valid = signatureCheck && chainCheck && receiptCheck;
return {
valid,
checks: {
signature: signatureCheck,
chain: chainCheck,
receipt: receiptCheck,
...(merkleCheck !== undefined ? { merkle: merkleCheck } : {}),
},
errors: errors.length > 0 ? errors : undefined,
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Build the signable portion of an EOR (excludes the signature field itself).
*/
function buildSignableEOR(eor: EOR): Record<string, unknown> {
return {
op_version: eor.op_version,
operation_id: eor.operation_id,
org_id: eor.org_id,
agent_id: eor.agent_id,
issued_at: eor.issued_at,
ttl_ms: eor.ttl_ms,
nonce: eor.nonce,
operation_type: eor.operation_type,
subject: eor.subject,
action: eor.action,
payload: eor.payload,
payload_hash: eor.payload_hash,
prev_chain_hash: eor.prev_chain_hash,
agent_pubkey_kid: eor.agent_pubkey_kid,
};
}
/**
* Validate all required EOR fields before processing.
*/
function validateEORFields(eor: EOR, receivedAt: number): void {
// Protocol version
if (eor.op_version !== '1.0') {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.unsupportedVersion', params: { version: eor.op_version } });
}
// Required string fields
const requiredStrings: Array<[string, unknown]> = [
['operation_id', eor.operation_id],
['org_id', eor.org_id],
['agent_id', eor.agent_id],
['nonce', eor.nonce],
['operation_type', eor.operation_type],
['payload_hash', eor.payload_hash],
['prev_chain_hash', eor.prev_chain_hash],
['agent_pubkey_kid', eor.agent_pubkey_kid],
['signature', eor.signature],
];
for (const [field, value] of requiredStrings) {
if (!value || typeof value !== 'string' || value.trim().length === 0) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.missingField', params: { field } });
}
}
// Nonce length
if (eor.nonce.length > MAX_NONCE_LENGTH) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.nonceTooLong', params: { max: MAX_NONCE_LENGTH } });
}
// issued_at must be a number
if (typeof eor.issued_at !== 'number' || eor.issued_at <= 0) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.invalidIssuedAt' });
}
// TTL bounds
if (typeof eor.ttl_ms !== 'number') {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.missingTtl' });
}
if (eor.ttl_ms < MIN_TTL_MS) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.ttlTooLow', params: { min: MIN_TTL_MS } });
}
if (eor.ttl_ms > MAX_TTL_MS) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.ttlTooHigh', params: { max: MAX_TTL_MS } });
}
// TTL expiration check
if (eor.issued_at + eor.ttl_ms < receivedAt) {
throw new AppError(400, 'TTL_EXPIRED');
}
// Payload size check (approximate via JSON serialization)
if (eor.payload !== null && eor.payload !== undefined) {
const payloadStr = typeof eor.payload === 'string' ? eor.payload : JSON.stringify(eor.payload);
if (new TextEncoder().encode(payloadStr).byteLength > MAX_PAYLOAD_SIZE) {
throw new AppError(400, 'PAYLOAD_TOO_LARGE');
}
}
}
@@ -0,0 +1,19 @@
import type { ErrorCode } from '../types/enums.js';
/** Map of all Elydora error codes to default human-readable messages */
export const ERROR_CODES: Readonly<Record<ErrorCode, string>> = {
INVALID_SIGNATURE: 'The operation signature is invalid.',
UNKNOWN_AGENT: 'The specified agent does not exist.',
KEY_REVOKED: 'The signing key has been revoked.',
AGENT_FROZEN: 'The agent is frozen and cannot submit operations.',
TTL_EXPIRED: 'The operation TTL has expired.',
REPLAY_DETECTED: 'A duplicate nonce was detected (replay attack).',
PREV_HASH_MISMATCH: 'The previous chain hash does not match.',
PAYLOAD_TOO_LARGE: 'The operation payload exceeds the maximum allowed size.',
RATE_LIMITED: 'Too many requests. Please retry later.',
INTERNAL_ERROR: 'An internal server error occurred.',
UNAUTHORIZED: 'Authentication is required.',
FORBIDDEN: 'You do not have permission to perform this action.',
NOT_FOUND: 'The requested resource was not found.',
VALIDATION_ERROR: 'The request failed validation.',
} as const;
@@ -0,0 +1,13 @@
export { ERROR_CODES } from './errors.js';
export {
MAX_PAYLOAD_SIZE,
MAX_TTL_MS,
MIN_TTL_MS,
MAX_NONCE_LENGTH,
STARTER_MAX_AGENTS,
DEFAULT_RETENTION_DAYS,
DEFAULT_EPOCH_INTERVAL_MS,
MAX_QUERY_LIMIT,
DEFAULT_QUERY_LIMIT,
DEFAULT_JWT_TTL_SECONDS,
} from './limits.js';
@@ -0,0 +1,29 @@
/** Maximum payload size in bytes (256 KB) */
export const MAX_PAYLOAD_SIZE = 256 * 1024;
/** Maximum TTL in milliseconds (5 minutes) */
export const MAX_TTL_MS = 300_000;
/** Minimum TTL in milliseconds (1 second) */
export const MIN_TTL_MS = 1_000;
/** Maximum nonce length in characters */
export const MAX_NONCE_LENGTH = 64;
/** Maximum number of agents on the Starter plan */
export const STARTER_MAX_AGENTS = 10;
/** Default data retention period in days */
export const DEFAULT_RETENTION_DAYS = 30;
/** Default epoch interval in milliseconds (5 minutes) */
export const DEFAULT_EPOCH_INTERVAL_MS = 300_000;
/** Maximum number of results per query */
export const MAX_QUERY_LIMIT = 1000;
/** Default number of results per query */
export const DEFAULT_QUERY_LIMIT = 100;
/** Default JWT token lifetime in seconds (24 hours) */
export const DEFAULT_JWT_TTL_SECONDS = 86400;
+66
View File
@@ -0,0 +1,66 @@
export type {
AgentStatus,
KeyStatus,
ExportStatus,
AdminAction,
RbacRole,
ErrorCode,
EOR,
ECH,
EAR,
EER,
Agent,
AgentKey,
Operation,
Receipt,
Epoch,
AdminEvent,
Export,
Organization,
User,
RegisterAgentRequest,
RegisterAgentResponse,
GetAgentResponse,
ListAgentsResponse,
FreezeAgentRequest,
UnfreezeAgentRequest,
RevokeAgentRequest,
UpdateAgentRequest,
SubmitOperationRequest,
SubmitOperationResponse,
GetOperationResponse,
VerifyOperationResponse,
AuditQueryRequest,
AuditQueryResponse,
GetEpochResponse,
ListEpochsResponse,
CreateExportRequest,
CreateExportResponse,
GetExportResponse,
ListExportsResponse,
AuthRegisterRequest,
AuthRegisterResponse,
AuthLoginRequest,
AuthLoginResponse,
AuthMeResponse,
AuthRefreshResponse,
IssueTokenRequest,
IssueTokenResponse,
JWK,
JWKSResponse,
ErrorResponse,
} from './types/index.js';
export {
ERROR_CODES,
MAX_PAYLOAD_SIZE,
MAX_TTL_MS,
MIN_TTL_MS,
MAX_NONCE_LENGTH,
STARTER_MAX_AGENTS,
DEFAULT_RETENTION_DAYS,
DEFAULT_EPOCH_INTERVAL_MS,
MAX_QUERY_LIMIT,
DEFAULT_QUERY_LIMIT,
DEFAULT_JWT_TTL_SECONDS,
} from './constants/index.js';
+213
View File
@@ -0,0 +1,213 @@
import type { Agent, AgentKey, Epoch, Export, Operation, Organization, Receipt, User } from './entities.js';
import type { ErrorCode } from './enums.js';
import type { EAR, EOR } from './protocol.js';
// ---------------------------------------------------------------------------
// Agent endpoints
// ---------------------------------------------------------------------------
export interface RegisterAgentRequest {
readonly agent_id: string;
readonly display_name?: string;
readonly responsible_entity?: string;
readonly integration_type?: string;
readonly keys: ReadonlyArray<{
readonly kid: string;
readonly public_key: string;
readonly algorithm: 'ed25519';
}>;
}
export interface RegisterAgentResponse {
readonly agent: Agent;
readonly keys: AgentKey[];
}
export interface GetAgentResponse {
readonly agent: Agent;
readonly keys: AgentKey[];
}
export interface ListAgentsResponse {
readonly agents: Agent[];
}
export interface FreezeAgentRequest {
readonly reason: string;
}
export interface UnfreezeAgentRequest {
readonly reason: string;
}
export interface RevokeAgentRequest {
readonly kid: string;
readonly reason: string;
}
export interface UpdateAgentRequest {
readonly integration_type: string;
}
// ---------------------------------------------------------------------------
// Operation endpoints
// ---------------------------------------------------------------------------
export type SubmitOperationRequest = EOR;
export interface SubmitOperationResponse {
readonly receipt: EAR;
}
export interface GetOperationResponse {
readonly operation: Operation;
readonly receipt?: Receipt;
readonly payload?: Record<string, unknown>;
}
export interface VerifyOperationResponse {
readonly valid: boolean;
readonly checks: {
readonly signature: boolean;
readonly chain: boolean;
readonly receipt: boolean;
readonly merkle?: boolean;
};
readonly errors?: string[];
}
// ---------------------------------------------------------------------------
// Audit query endpoints
// ---------------------------------------------------------------------------
export interface AuditQueryRequest {
readonly org_id?: string;
readonly agent_id?: string;
readonly operation_type?: string;
readonly start_time?: number;
readonly end_time?: number;
readonly cursor?: string;
readonly limit?: number;
}
export interface AuditQueryResponse {
readonly operations: Operation[];
readonly cursor?: string;
readonly total_count: number;
}
// ---------------------------------------------------------------------------
// Epoch endpoints
// ---------------------------------------------------------------------------
export interface GetEpochResponse {
readonly epoch: Epoch;
readonly anchor?: {
readonly tsa_token?: string;
readonly tsa_url?: string;
readonly anchored_at?: number;
};
}
export interface ListEpochsResponse {
readonly epochs: Epoch[];
}
// ---------------------------------------------------------------------------
// Export endpoints
// ---------------------------------------------------------------------------
export interface CreateExportRequest {
readonly start_time: number;
readonly end_time: number;
readonly agent_id?: string;
readonly operation_type?: string;
readonly format: 'json' | 'pdf';
}
export interface CreateExportResponse {
readonly export: Export;
}
export interface GetExportResponse {
readonly export: Export;
readonly download_url?: string;
}
export interface ListExportsResponse {
readonly exports: Export[];
}
// ---------------------------------------------------------------------------
// JWKS endpoint
// ---------------------------------------------------------------------------
export interface JWK {
readonly kty: string;
readonly crv?: string;
readonly x?: string;
readonly kid: string;
readonly use: string;
readonly alg: string;
}
export interface JWKSResponse {
readonly keys: JWK[];
}
// ---------------------------------------------------------------------------
// Auth endpoints
// ---------------------------------------------------------------------------
export interface AuthRegisterRequest {
readonly email: string;
readonly password: string;
readonly display_name?: string;
readonly org_name?: string;
}
export interface AuthRegisterResponse {
readonly user: User;
readonly organization: Organization;
readonly token: string;
}
export interface AuthLoginRequest {
readonly email: string;
readonly password: string;
}
export interface AuthLoginResponse {
readonly user: User;
readonly token: string;
}
export interface AuthMeResponse {
readonly user: User;
}
export interface AuthRefreshResponse {
readonly token: string;
}
export interface IssueTokenRequest {
readonly ttl_seconds?: number | null; // null = never expire
}
export interface IssueTokenResponse {
readonly token: string;
readonly expires_at: number | null; // null = never expire
}
// ---------------------------------------------------------------------------
// Error response
// ---------------------------------------------------------------------------
export interface ErrorResponse {
readonly error: {
readonly code: ErrorCode;
readonly message: string;
readonly request_id: string;
readonly details?: Record<string, unknown>;
};
}
@@ -0,0 +1,109 @@
import type { AgentStatus, ExportStatus, KeyStatus, RbacRole } from './enums.js';
/** Registered AI agent within an organization */
export interface Agent {
readonly agent_id: string;
readonly org_id: string;
readonly display_name: string;
readonly responsible_entity: string;
readonly integration_type: string;
readonly status: AgentStatus;
readonly created_at: number;
readonly updated_at: number;
}
/** Public key associated with an agent for signing operations */
export interface AgentKey {
readonly kid: string;
readonly agent_id: string;
/** Base64-encoded public key (stored as BLOB in D1) */
readonly public_key: string;
readonly algorithm: 'ed25519';
readonly status: KeyStatus;
readonly created_at: number;
readonly retired_at: number | null;
}
/** Persisted operation record in D1 */
export interface Operation {
readonly operation_id: string;
readonly org_id: string;
readonly agent_id: string;
readonly seq_no: number;
readonly operation_type: string;
readonly issued_at: number;
readonly ttl_ms: number;
readonly nonce: string;
readonly subject: string;
readonly action: string;
readonly payload_hash: string;
readonly prev_chain_hash: string;
readonly chain_hash: string;
readonly agent_pubkey_kid: string;
readonly signature: string;
readonly r2_payload_key: string | null;
readonly created_at: number;
}
/** Receipt stored in R2, referenced from D1 */
export interface Receipt {
readonly receipt_id: string;
readonly operation_id: string;
readonly r2_receipt_key: string;
readonly created_at: number;
}
/** Periodic Merkle epoch rollup record */
export interface Epoch {
readonly epoch_id: string;
readonly org_id: string;
readonly start_time: number;
readonly end_time: number;
readonly root_hash: string;
readonly leaf_count: number;
readonly r2_epoch_key: string;
readonly created_at: number;
}
/** Admin audit event log entry */
export interface AdminEvent {
readonly event_id: string;
readonly org_id: string;
readonly actor: string;
readonly action: string;
readonly target_type: string;
readonly target_id: string;
readonly details: string | null;
readonly created_at: number;
}
/** Organization */
export interface Organization {
readonly org_id: string;
readonly name: string;
readonly created_at: number;
readonly updated_at: number;
}
/** Console user within an organization */
export interface User {
readonly user_id: string;
readonly org_id: string;
readonly email: string;
readonly display_name: string;
readonly role: RbacRole;
readonly status: 'active' | 'suspended';
readonly created_at: number;
readonly updated_at: number;
}
/** Compliance export job */
export interface Export {
readonly export_id: string;
readonly org_id: string;
readonly status: ExportStatus;
readonly query_params: string;
readonly r2_export_key: string | null;
readonly created_at: number;
readonly completed_at: number | null;
}
+44
View File
@@ -0,0 +1,44 @@
/** Agent lifecycle status */
export type AgentStatus = 'active' | 'frozen' | 'revoked';
/** Agent key lifecycle status */
export type KeyStatus = 'active' | 'retired' | 'revoked';
/** Export job status */
export type ExportStatus = 'queued' | 'running' | 'done' | 'failed';
/** Administrative action types for audit logging */
export type AdminAction =
| 'agent.register'
| 'agent.update'
| 'agent.freeze'
| 'agent.unfreeze'
| 'agent.revoke'
| 'agent.delete'
| 'key.revoke'
| 'export.create';
/** RBAC roles for console access */
export type RbacRole =
| 'org_owner'
| 'security_admin'
| 'compliance_auditor'
| 'readonly_investigator'
| 'integration_engineer';
/** Error codes returned by the API */
export type ErrorCode =
| 'INVALID_SIGNATURE'
| 'UNKNOWN_AGENT'
| 'KEY_REVOKED'
| 'AGENT_FROZEN'
| 'TTL_EXPIRED'
| 'REPLAY_DETECTED'
| 'PREV_HASH_MISMATCH'
| 'PAYLOAD_TOO_LARGE'
| 'RATE_LIMITED'
| 'INTERNAL_ERROR'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
| 'NOT_FOUND'
| 'VALIDATION_ERROR';
+61
View File
@@ -0,0 +1,61 @@
export type {
AgentStatus,
KeyStatus,
ExportStatus,
AdminAction,
RbacRole,
ErrorCode,
} from './enums.js';
export type {
EOR,
ECH,
EAR,
EER,
} from './protocol.js';
export type {
Agent,
AgentKey,
Operation,
Receipt,
Epoch,
AdminEvent,
Export,
Organization,
User,
} from './entities.js';
export type {
RegisterAgentRequest,
RegisterAgentResponse,
GetAgentResponse,
ListAgentsResponse,
FreezeAgentRequest,
UnfreezeAgentRequest,
RevokeAgentRequest,
UpdateAgentRequest,
SubmitOperationRequest,
SubmitOperationResponse,
GetOperationResponse,
VerifyOperationResponse,
AuditQueryRequest,
AuditQueryResponse,
GetEpochResponse,
ListEpochsResponse,
CreateExportRequest,
CreateExportResponse,
GetExportResponse,
ListExportsResponse,
AuthRegisterRequest,
AuthRegisterResponse,
AuthLoginRequest,
AuthLoginResponse,
AuthMeResponse,
AuthRefreshResponse,
IssueTokenRequest,
IssueTokenResponse,
JWK,
JWKSResponse,
ErrorResponse,
} from './api.js';
@@ -0,0 +1,112 @@
/**
* Elydora Operation Record (EOR)
*
* The fundamental unit of auditable activity in the Elydora protocol.
* Signed by the agent's Ed25519 key and chained via prev_chain_hash.
*/
export interface EOR {
/** Protocol version, always "1.0" */
readonly op_version: '1.0';
/** Unique operation identifier (UUIDv7) */
readonly operation_id: string;
/** Organization identifier */
readonly org_id: string;
/** Agent that issued this operation */
readonly agent_id: string;
/** Timestamp when issued (Unix milliseconds) */
readonly issued_at: number;
/** Time-to-live in milliseconds */
readonly ttl_ms: number;
/** Replay-prevention nonce (base64url) */
readonly nonce: string;
/** Classification of the operation */
readonly operation_type: string;
/** Subject of the operation */
readonly subject: Record<string, unknown>;
/** Action performed */
readonly action: Record<string, unknown>;
/** Operation payload */
readonly payload: Record<string, unknown> | string | null;
/** SHA-256 hash of the payload (base64url) */
readonly payload_hash: string;
/** Hash of the previous operation in the agent's chain (base64url) */
readonly prev_chain_hash: string;
/** Key ID of the agent's signing key */
readonly agent_pubkey_kid: string;
/** Ed25519 signature over the canonical EOR (base64url) */
readonly signature: string;
}
/**
* Elydora Chain Hash (ECH)
*
* Represents the chain hash computation linking operations together.
*/
export interface ECH {
/** Previous chain hash (base64url) */
readonly prev_ech: string;
/** SHA-256 hash of the operation payload (base64url) */
readonly payload_hash: string;
/** Operation identifier (UUIDv7) */
readonly operation_id: string;
/** Timestamp when issued (Unix milliseconds) */
readonly issued_at: number;
/** Resulting chain hash (base64url) */
readonly chain_hash: string;
}
/**
* Elydora Acknowledgment Receipt (EAR)
*
* Server-issued receipt confirming acceptance of an operation.
*/
export interface EAR {
/** Receipt version */
readonly receipt_version: string;
/** Unique receipt identifier */
readonly receipt_id: string;
/** Operation this receipt acknowledges */
readonly operation_id: string;
/** Organization identifier */
readonly org_id: string;
/** Agent that submitted the operation */
readonly agent_id: string;
/** Timestamp when the server received the operation (Unix ms) */
readonly server_received_at: number;
/** Sequence number assigned to the operation */
readonly seq_no: number;
/** Chain hash at time of receipt */
readonly chain_hash: string;
/** Queue message ID for async processing */
readonly queue_message_id: string;
/** Hash of this receipt */
readonly receipt_hash: string;
/** Elydora server key ID used for signing */
readonly elydora_kid: string;
/** Elydora server signature over the receipt */
readonly elydora_signature: string;
}
/**
* Elydora Epoch Record (EER)
*
* Periodic Merkle-tree rollup anchoring operations to a root hash.
*/
export interface EER {
/** Unique epoch identifier */
readonly epoch_id: string;
/** Organization identifier */
readonly org_id: string;
/** Epoch start time (Unix ms) */
readonly start_time: number;
/** Epoch end time (Unix ms) */
readonly end_time: number;
/** Number of leaf operations in this epoch */
readonly leaf_count: number;
/** Merkle root hash */
readonly root_hash: string;
/** Hash algorithm used (e.g. "sha256") */
readonly hash_alg: string;
/** Elydora server signature over the epoch record */
readonly signature_by_elydora: string;
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Environment bindings for the ElydoraOpenSource Node.js server.
*
* Replaces Cloudflare-specific bindings (D1Database, R2Bucket, KVNamespace,
* Queue) with adapter interfaces backed by PostgreSQL, MinIO, Redis, and
* BullMQ respectively.
*/
import type { Database, ObjectStore, Cache, MessageQueue } from './adapters/interfaces.js';
export interface Env {
/** PostgreSQL adapter for relational storage */
ELYDORA_DB: Database;
/** MinIO/S3 adapter for evidence payloads, receipts, and export bundles */
ELYDORA_EVIDENCE: ObjectStore;
/** Redis adapter for caching (nonces, rate-limit counters, etc.) */
ELYDORA_CACHE: Cache;
/** BullMQ adapter for async operation processing and export jobs */
ELYDORA_QUEUE: MessageQueue;
/** Deployment environment identifier (e.g. "production", "staging") */
ENVIRONMENT: string;
/** Current API version (e.g. "v1") */
API_VERSION: string;
/** Current Elydora protocol version (e.g. "1.0") */
PROTOCOL_VERSION: string;
/** HMAC secret used for JWT verification */
JWT_SECRET: string;
/** Base64url-encoded Ed25519 private key used by the server to sign receipts */
ELYDORA_SIGNING_KEY: string;
/** Comma-separated list of allowed CORS origins (used in production) */
ALLOWED_ORIGINS: string;
/** Optional TSA endpoint URL (defaults to Sectigo if unset) */
TSA_URL?: string;
}
/**
* Variables set by middleware and available via `c.get(...)` / `c.var`.
*/
export interface AppVariables {
/** UUIDv7 request identifier injected by the request-id middleware */
request_id: string;
/** Organization identifier extracted from the verified JWT */
org_id: string;
/** RBAC role extracted from the verified JWT */
role: import('./shared/index.js').RbacRole;
/** Actor identifier (sub claim) extracted from the verified JWT */
actor: string;
/** Detected language from Accept-Language header (set by i18n middleware) */
lang: import('./i18n/messages.js').Lang;
}
+279
View File
@@ -0,0 +1,279 @@
/**
* Cryptographic utilities for the Elydora API.
*
* All operations use the Web Crypto API (SubtleCrypto) which is available
* natively in Node.js 20+ with full Ed25519 support.
*/
// ---------------------------------------------------------------------------
// Base64url helpers (RFC 4648 section 5)
// ---------------------------------------------------------------------------
/** Decode a base64url string to a Uint8Array. */
export function base64urlDecode(input: string): Uint8Array {
// Convert base64url to standard base64
let base64 = input.replace(/-/g, '+').replace(/_/g, '/');
// Pad to a multiple of 4
while (base64.length % 4 !== 0) {
base64 += '=';
}
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/** Encode a Uint8Array to a base64url string (no padding). */
export function base64urlEncode(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]!);
}
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
// ---------------------------------------------------------------------------
// SHA-256
// ---------------------------------------------------------------------------
/** Compute SHA-256 of arbitrary data and return base64url. */
export async function sha256Base64url(data: BufferSource | string): Promise<string> {
const input = typeof data === 'string' ? new TextEncoder().encode(data) : data;
const hash = await crypto.subtle.digest('SHA-256', input);
return base64urlEncode(new Uint8Array(hash));
}
// ---------------------------------------------------------------------------
// Ed25519 signature verification
// ---------------------------------------------------------------------------
/**
* Import a raw Ed25519 public key (32 bytes) as a CryptoKey.
*
* @param publicKeyBytes - 32-byte raw Ed25519 public key
*/
export async function importEd25519PublicKey(publicKeyBytes: Uint8Array): Promise<CryptoKey> {
return crypto.subtle.importKey(
'raw',
publicKeyBytes as unknown as Uint8Array<ArrayBuffer>,
{ name: 'Ed25519' },
true, // extractable
['verify'],
);
}
/**
* Verify an Ed25519 signature over data.
*
* @param publicKeyBytes - 32-byte raw public key
* @param signatureBytes - 64-byte Ed25519 signature
* @param data - The signed data
* @returns true if the signature is valid
*/
export async function verifyEd25519Signature(
publicKeyBytes: Uint8Array,
signatureBytes: Uint8Array,
data: Uint8Array,
): Promise<boolean> {
try {
const key = await importEd25519PublicKey(publicKeyBytes);
return crypto.subtle.verify(
{ name: 'Ed25519' },
key,
signatureBytes as unknown as Uint8Array<ArrayBuffer>,
data as unknown as Uint8Array<ArrayBuffer>,
);
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Ed25519 signing (server-side receipt signing)
// ---------------------------------------------------------------------------
/**
* PKCS8 DER prefix for wrapping a 32-byte Ed25519 seed into a valid PKCS8 key.
*
* SEQUENCE {
* INTEGER 0
* SEQUENCE { OID 1.3.101.112 }
* OCTET STRING { OCTET STRING { <32 bytes> } }
* }
*/
const ED25519_PKCS8_PREFIX = new Uint8Array([
0x30, 0x2e, // SEQUENCE (46 bytes)
0x02, 0x01, 0x00, // INTEGER 0
0x30, 0x05, // SEQUENCE (5 bytes)
0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112 (Ed25519)
0x04, 0x22, // OCTET STRING (34 bytes)
0x04, 0x20, // OCTET STRING (32 bytes)
]);
/** Wrap a 32-byte Ed25519 seed in a PKCS8 envelope. */
function wrapSeedAsPkcs8(seed: Uint8Array): Uint8Array {
const pkcs8 = new Uint8Array(ED25519_PKCS8_PREFIX.length + seed.length);
pkcs8.set(ED25519_PKCS8_PREFIX);
pkcs8.set(seed, ED25519_PKCS8_PREFIX.length);
return pkcs8;
}
/**
* Import a raw Ed25519 private key for signing.
*
* Node.js expects PKCS8 format for Ed25519 private keys.
* We wrap the 32-byte seed in a minimal PKCS8 envelope.
*
* @param privateKeyBase64url - base64url-encoded 32-byte Ed25519 private key seed
*/
export async function importEd25519PrivateKey(privateKeyBase64url: string): Promise<CryptoKey> {
const seed = base64urlDecode(privateKeyBase64url);
const pkcs8 = wrapSeedAsPkcs8(seed);
return crypto.subtle.importKey(
'pkcs8',
pkcs8 as unknown as Uint8Array<ArrayBuffer>,
{ name: 'Ed25519' },
false,
['sign'],
);
}
/**
* Sign data with an Ed25519 private key.
*
* @param privateKeyBase64url - base64url-encoded 32-byte private key seed
* @param data - data to sign
* @returns base64url-encoded 64-byte signature
*/
export async function signEd25519(
privateKeyBase64url: string,
data: Uint8Array,
): Promise<string> {
const key = await importEd25519PrivateKey(privateKeyBase64url);
const signature = await crypto.subtle.sign(
{ name: 'Ed25519' },
key,
data as unknown as Uint8Array<ArrayBuffer>,
);
return base64urlEncode(new Uint8Array(signature));
}
/**
* Export the public key from an Ed25519 private key (for JWKS).
*
* @param privateKeyBase64url - base64url-encoded 32-byte private key seed
* @returns base64url-encoded 32-byte public key
*/
export async function deriveEd25519PublicKey(privateKeyBase64url: string): Promise<string> {
const seed = base64urlDecode(privateKeyBase64url);
const pkcs8 = wrapSeedAsPkcs8(seed);
const key = await crypto.subtle.importKey(
'pkcs8',
pkcs8 as unknown as Uint8Array<ArrayBuffer>,
{ name: 'Ed25519' },
true, // extractable — needed to export the public key
['sign'],
);
// Export as JWK to get the public 'x' parameter
const jwk = await crypto.subtle.exportKey('jwk', key) as JsonWebKey;
// The 'x' field in a JWK for OKP/Ed25519 is the base64url-encoded public key
return jwk.x ?? '';
}
// ---------------------------------------------------------------------------
// JCS (JSON Canonicalization Scheme - RFC 8785)
// ---------------------------------------------------------------------------
/**
* Canonicalize a JSON value according to JCS (RFC 8785).
*
* JCS specifies:
* - Object keys sorted lexicographically by UTF-16 code units
* - No whitespace
* - Numbers serialized using ES2015 Number serialization
* - Strings serialized with minimal escaping per JSON spec
*/
export function jcsCanonicalise(value: unknown): string {
if (value === null || value === undefined) {
return 'null';
}
if (typeof value === 'boolean' || typeof value === 'number') {
return JSON.stringify(value);
}
if (typeof value === 'string') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
const elements = value.map((v) => jcsCanonicalise(v));
return '[' + elements.join(',') + ']';
}
if (typeof value === 'object') {
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
const pairs: string[] = [];
for (const key of keys) {
if (obj[key] !== undefined) {
pairs.push(JSON.stringify(key) + ':' + jcsCanonicalise(obj[key]));
}
}
return '{' + pairs.join(',') + '}';
}
return JSON.stringify(value);
}
// ---------------------------------------------------------------------------
// Chain hash computation
// ---------------------------------------------------------------------------
/**
* Compute the chain hash for an operation.
*
* chain_hash = SHA-256(prev_chain_hash + payload_hash + operation_id + issued_at)
*
* All inputs are concatenated as UTF-8 strings separated by '|'.
*/
export async function computeChainHash(
prevChainHash: string,
payloadHash: string,
operationId: string,
issuedAt: number,
): Promise<string> {
const input = `${prevChainHash}|${payloadHash}|${operationId}|${issuedAt}`;
return sha256Base64url(input);
}
// ---------------------------------------------------------------------------
// Receipt hash computation
// ---------------------------------------------------------------------------
/**
* Compute the hash of an EAR receipt for signing.
*
* Hashes the canonicalized receipt fields (excluding the signature fields themselves).
*/
export async function computeReceiptHash(receiptFields: {
receipt_version: string;
receipt_id: string;
operation_id: string;
org_id: string;
agent_id: string;
server_received_at: number;
seq_no: number;
chain_hash: string;
queue_message_id: string;
}): Promise<string> {
const canonical = jcsCanonicalise(receiptFields);
return sha256Base64url(canonical);
}
+162
View File
@@ -0,0 +1,162 @@
/**
* SHA-256 Merkle tree implementation for epoch rollups.
*
* Leaf hashes are sorted lexicographically before tree construction.
* Internal nodes are computed by concatenating the raw bytes (not base64url
* strings) of their children and hashing the result with SHA-256.
*/
import { sha256Base64url, base64urlDecode } from './crypto.js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface MerkleTree {
/** Base64url SHA-256 root hash */
root: string;
/** Sorted leaf hashes */
leaves: string[];
/** Operation IDs matching sorted leaves */
leafOps: string[];
/** All tree layers from leaves (index 0) to root (last index) */
layers: string[][];
}
export interface MerkleProof {
/** The leaf hash being proved */
leaf: string;
/** Sibling hashes along the path to the root */
siblings: string[];
/** Direction of each sibling ('left' if sibling is on the left) */
directions: ('left' | 'right')[];
/** The expected root hash */
root: string;
}
// ---------------------------------------------------------------------------
// Internal helper
// ---------------------------------------------------------------------------
/**
* Compute the parent hash from two child hashes.
* Decodes both base64url hashes to raw bytes, concatenates, then SHA-256.
*/
async function hashPair(left: string, right: string): Promise<string> {
const leftBytes = base64urlDecode(left);
const rightBytes = base64urlDecode(right);
const combined = new Uint8Array(leftBytes.length + rightBytes.length);
combined.set(leftBytes);
combined.set(rightBytes, leftBytes.length);
return sha256Base64url(combined);
}
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
/**
* Build a Merkle tree from leaf hashes and their corresponding operation IDs.
*
* Leaves are sorted lexicographically (with operationIds reordered to match).
* If a layer has an odd number of nodes the last node is duplicated.
*/
export async function buildMerkleTree(
leafHashes: string[],
operationIds: string[],
): Promise<MerkleTree> {
// Pair up leaves with their operation IDs and sort by leaf hash
const paired = leafHashes.map((hash, i) => ({
hash,
opId: operationIds[i]!,
}));
paired.sort((a, b) => (a.hash < b.hash ? -1 : a.hash > b.hash ? 1 : 0));
const sortedLeaves = paired.map((p) => p.hash);
const sortedOps = paired.map((p) => p.opId);
const layers: string[][] = [sortedLeaves];
let currentLayer = sortedLeaves;
while (currentLayer.length > 1) {
const nextLayer: string[] = [];
// If odd, duplicate the last node
if (currentLayer.length % 2 !== 0) {
currentLayer = [...currentLayer, currentLayer[currentLayer.length - 1]!];
}
for (let i = 0; i < currentLayer.length; i += 2) {
nextLayer.push(await hashPair(currentLayer[i]!, currentLayer[i + 1]!));
}
layers.push(nextLayer);
currentLayer = nextLayer;
}
return {
root: currentLayer[0]!,
leaves: sortedLeaves,
leafOps: sortedOps,
layers,
};
}
// ---------------------------------------------------------------------------
// Proof generation
// ---------------------------------------------------------------------------
/**
* Generate a Merkle inclusion proof for a given leaf hash.
*
* Returns null if the leaf is not found in the tree.
*/
export function getMerkleProof(
tree: MerkleTree,
leafHash: string,
): MerkleProof | null {
let index = tree.leaves.indexOf(leafHash);
if (index === -1) return null;
const siblings: string[] = [];
const directions: ('left' | 'right')[] = [];
for (let layerIdx = 0; layerIdx < tree.layers.length - 1; layerIdx++) {
let layer = tree.layers[layerIdx]!;
// Duplicate last node if odd (mirrors build logic)
if (layer.length % 2 !== 0) {
layer = [...layer, layer[layer.length - 1]!];
}
const siblingIndex = index % 2 === 0 ? index + 1 : index - 1;
siblings.push(layer[siblingIndex]!);
directions.push(index % 2 === 0 ? 'right' : 'left');
index = Math.floor(index / 2);
}
return {
leaf: leafHash,
siblings,
directions,
root: tree.root,
};
}
// ---------------------------------------------------------------------------
// Proof verification
// ---------------------------------------------------------------------------
/**
* Verify a Merkle inclusion proof by recomputing the root.
*/
export async function verifyMerkleProof(proof: MerkleProof): Promise<boolean> {
let current = proof.leaf;
for (let i = 0; i < proof.siblings.length; i++) {
const sibling = proof.siblings[i]!;
const direction = proof.directions[i]!;
if (direction === 'left') {
current = await hashPair(sibling, current);
} else {
current = await hashPair(current, sibling);
}
}
return current === proof.root;
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Cursor-based pagination helpers.
*
* Cursors are opaque base64url-encoded JSON objects containing the
* created_at timestamp and primary key of the last item in the current
* page. This design avoids OFFSET-based pagination which degrades at
* scale and is susceptible to drift when rows are inserted concurrently.
*/
import { base64urlDecode, base64urlEncode } from './crypto.js';
/** Shape of the decoded cursor payload. */
export interface CursorPayload {
/** created_at of the last item on the previous page */
readonly created_at: number;
/** Primary key of the last item on the previous page */
readonly id: string;
}
/**
* Encode a cursor payload into an opaque cursor string.
*
* @param payload - The cursor data to encode
* @returns An opaque base64url-encoded cursor string
*/
export function encodeCursor(payload: CursorPayload): string {
const json = JSON.stringify(payload);
const bytes = new TextEncoder().encode(json);
return base64urlEncode(bytes);
}
/**
* Decode an opaque cursor string back into its payload.
*
* @param cursor - The opaque cursor string
* @returns The decoded cursor payload, or null if invalid
*/
export function decodeCursor(cursor: string): CursorPayload | null {
try {
const bytes = base64urlDecode(cursor);
const json = new TextDecoder().decode(bytes);
const parsed = JSON.parse(json);
if (
typeof parsed !== 'object' ||
parsed === null ||
typeof parsed.created_at !== 'number' ||
typeof parsed.id !== 'string'
) {
return null;
}
return { created_at: parsed.created_at, id: parsed.id };
} catch {
return null;
}
}
+381
View File
@@ -0,0 +1,381 @@
/**
* Branded PDF generator for Elydora compliance exports.
*
* Produces professional multi-page PDF/1.4 documents with:
* - Elydora logo and brand header bar
* - Structured metadata section
* - Formatted operations table with alternating row stripes
* - Branded footer with page numbers
*
* Uses only built-in PDF Type1 fonts (Helvetica, Courier) —
* no external dependencies, works entirely in the Node.js runtime.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface PDFReportData {
export_id: string;
org_id: string;
generated_at: string;
time_range: string;
agent_filter: string;
type_filter: string;
total: number;
operations: Array<{
operation_id: string;
agent_id: string;
operation_type: string;
seq_no: number;
chain_hash: string;
payload_hash: string;
created_at: number;
}>;
}
// ---------------------------------------------------------------------------
// Page layout constants (US Letter: 612 x 792 pt)
// ---------------------------------------------------------------------------
const PW = 612;
const PH = 792;
const ML = 56; // left margin
const MR = 56; // right margin
const CW = PW - ML - MR; // 500pt content width
// Header bar
const HDR_H = 56;
const HDR_Y = PH - HDR_H; // 736 — bottom edge of header
// Footer
const FTR_LINE = 44; // y of separator line
const FTR_TEXT = 28; // y of footer text baseline
// Content area
const CT = HDR_Y - 20; // 716 — content top
const CB = FTR_LINE + 12; // 56 — content bottom
// Operations table
const ROW_H = 12; // row height
const TBL_HDR = 22; // table header + separator + gap
// Metadata section (first page only)
const META_H = 172;
// Table column x-positions
const COL_SEQ = ML;
const COL_TIME = ML + 38;
const COL_OPID = ML + 142;
const COL_AGENT = ML + 296;
const COL_TYPE = ML + 398;
// ---------------------------------------------------------------------------
// Brand colours (RGB 0‑1 scale)
// ---------------------------------------------------------------------------
const INK = '0.071 0.071 0.071'; // #121212
const BG = '0.918 0.918 0.898'; // #EAEAE5
const DIM = '0.400 0.400 0.400'; // #666666
const BORDER = '0.753 0.753 0.729'; // #C0C0BA
const HDR_SUB = '0.620 0.620 0.600'; // subtitle on dark header
const STRIPE = '0.955 0.955 0.942'; // subtle even-row background
// ---------------------------------------------------------------------------
// PDF drawing primitives
// ---------------------------------------------------------------------------
/** Escape special chars and strip non-ASCII for PDF text strings. */
function esc(s: string): string {
return s
.replace(/[^\x20-\x7E]/g, '?')
.replace(/\\/g, '\\\\')
.replace(/\(/g, '\\(')
.replace(/\)/g, '\\)');
}
/** Truncate string with ".." suffix. */
function trunc(s: string, max: number): string {
return s.length <= max ? s : s.slice(0, max - 2) + '..';
}
/** Filled rectangle. */
function fillRect(x: number, y: number, w: number, h: number, rgb: string): string {
return `${rgb} rg ${x} ${y} ${w} ${h} re f`;
}
/** Horizontal line. */
function hline(x1: number, y: number, x2: number, rgb: string, w = 0.5): string {
return `${rgb} RG ${w} w ${x1} ${y} m ${x2} ${y} l S`;
}
/** Text at absolute position. */
function txt(s: string, x: number, y: number, font: string, size: number, rgb: string): string {
return `BT ${rgb} rg ${font} ${size} Tf 1 0 0 1 ${x} ${y} Tm (${esc(s)}) Tj ET`;
}
/**
* Draw the Elydora "E" logo mark.
* SVG source: M3 2H21V7H8V10H17V15H8V18H21V23H3V2Z (viewBox 0 0 24 24)
* Transform flips Y for PDF coordinate system.
*/
function drawLogo(x: number, y: number, size: number, rgb: string): string {
const sc = size / 24;
return [
'q',
`${sc} 0 0 ${-sc} ${x} ${y + size} cm`,
`${rgb} rg`,
'3 2 m 21 2 l 21 7 l 8 7 l 8 10 l 17 10 l 17 15 l 8 15 l 8 18 l 21 18 l 21 23 l 3 23 l h f',
'Q',
].join('\n');
}
// ---------------------------------------------------------------------------
// Section renderers — each appends drawing instructions to the page array
// ---------------------------------------------------------------------------
/** Dark header bar with logo and brand text (every page). */
function renderHeader(p: string[]): void {
// Full-width dark bar
p.push(fillRect(0, HDR_Y, PW, HDR_H, INK));
// Thin accent line at bottom edge
p.push(hline(0, HDR_Y, PW, BG, 1.5));
// Logo mark
const sz = 30;
const lx = ML;
const ly = HDR_Y + (HDR_H - sz) / 2; // vertically centred
p.push(drawLogo(lx, ly, sz, BG));
// Brand name
const tx = lx + sz + 12;
p.push(txt('ELYDORA', tx, ly + sz - 16, '/F1', 18, BG));
// Subtitle
p.push(txt('COMPLIANCE EXPORT REPORT', tx, ly + 2, '/F2', 8, HDR_SUB));
}
/** Footer with confidentiality note and page number (every page). */
function renderFooter(p: string[], pageNum: number, totalPages: number, exportId: string): void {
p.push(hline(ML, FTR_LINE, PW - MR, BORDER, 0.5));
p.push(txt('CONFIDENTIAL', ML, FTR_TEXT, '/F2', 7, DIM));
p.push(txt(`Export ${exportId.slice(0, 8)}`, PW / 2 - 28, FTR_TEXT, '/F3', 7, DIM));
p.push(txt(`Page ${pageNum} of ${totalPages}`, PW - MR - 56, FTR_TEXT, '/F2', 7, DIM));
}
/** Metadata key-value section (first page only). Returns new y position. */
function renderMetadata(p: string[], data: PDFReportData): number {
let y = CT;
// Section title
p.push(txt('EXPORT DETAILS', ML, y, '/F1', 10, INK));
y -= 6;
p.push(hline(ML, y, PW - MR, BORDER, 0.5));
y -= 18;
// Key-value rows
const valX = ML + 112;
const fields: [string, string][] = [
['Export ID', data.export_id],
['Organization', data.org_id],
['Generated', data.generated_at],
['Time Range', data.time_range],
['Agent Filter', data.agent_filter],
['Type Filter', data.type_filter],
['Total Records', String(data.total)],
];
for (const [label, value] of fields) {
p.push(txt(label, ML, y, '/F2', 8.5, DIM));
p.push(txt(value, valX, y, '/F3', 8.5, INK));
y -= 16;
}
y -= 8;
// Operations section title
p.push(txt('OPERATIONS', ML, y, '/F1', 10, INK));
y -= 6;
p.push(hline(ML, y, PW - MR, BORDER, 0.5));
y -= 4;
return y;
}
/** Table column headers. Returns y of first data row. */
function renderTableHeader(p: string[], y: number): number {
const hy = y - 14;
p.push(txt('#', COL_SEQ, hy, '/F1', 7.5, DIM));
p.push(txt('TIMESTAMP', COL_TIME, hy, '/F1', 7.5, DIM));
p.push(txt('OPERATION ID', COL_OPID, hy, '/F1', 7.5, DIM));
p.push(txt('AGENT', COL_AGENT, hy, '/F1', 7.5, DIM));
p.push(txt('TYPE', COL_TYPE, hy, '/F1', 7.5, DIM));
const sy = hy - 5;
p.push(hline(ML, sy, PW - MR, BORDER, 0.3));
return sy - 3;
}
/** Single operation data row. */
function renderRow(
p: string[],
y: number,
op: PDFReportData['operations'][number],
index: number,
): void {
// Alternating stripe background
if (index % 2 === 0) {
p.push(fillRect(ML - 4, y - 3, CW + 8, ROW_H, STRIPE));
}
const ts = new Date(op.created_at).toISOString().slice(0, 19).replace('T', ' ');
p.push(txt(String(op.seq_no).padStart(4), COL_SEQ, y, '/F3', 8, DIM));
p.push(txt(ts, COL_TIME, y, '/F3', 8, INK));
p.push(txt(trunc(op.operation_id, 30), COL_OPID, y, '/F3', 8, INK));
p.push(txt(trunc(op.agent_id, 18), COL_AGENT, y, '/F3', 8, INK));
p.push(txt(trunc(op.operation_type, 20), COL_TYPE, y, '/F3', 8, INK));
}
/** End-of-report note (last page, if space allows). */
function renderEndNote(p: string[], y: number): void {
y -= 20;
p.push(hline(ML, y, PW - MR, BORDER, 0.3));
y -= 18;
p.push(txt('End of Report', ML, y, '/F1', 9, DIM));
y -= 14;
p.push(txt(
'Full cryptographic chain and payload hashes are available in JSON export format.',
ML, y, '/F2', 7.5, DIM,
));
y -= 12;
p.push(txt(
'Elydora - The Responsibility Layer for AI Agents',
ML, y, '/F2', 7.5, DIM,
));
}
// ---------------------------------------------------------------------------
// Main export
// ---------------------------------------------------------------------------
export function buildBrandedPDF(data: PDFReportData): Uint8Array {
const ops = data.operations;
const totalOps = ops.length;
// Calculate pagination
const firstPageArea = CT - META_H - CB; // ~488pt
const opsFirstPage = Math.max(0, Math.floor((firstPageArea - TBL_HDR) / ROW_H));
const fullPageArea = CT - CB; // ~660pt
const opsPerPage = Math.floor((fullPageArea - TBL_HDR) / ROW_H);
const totalPages = totalOps <= opsFirstPage
? 1
: 1 + Math.ceil((totalOps - opsFirstPage) / Math.max(1, opsPerPage));
// Build content streams (one per page)
const streams: string[] = [];
let opIdx = 0;
for (let pg = 0; pg < totalPages; pg++) {
const p: string[] = [];
renderHeader(p);
let y: number;
if (pg === 0) {
y = renderMetadata(p, data);
} else {
y = CT;
}
y = renderTableHeader(p, y);
const maxOps = pg === 0 ? opsFirstPage : opsPerPage;
const endIdx = Math.min(opIdx + maxOps, totalOps);
while (opIdx < endIdx) {
renderRow(p, y, ops[opIdx]!, opIdx);
y -= ROW_H;
opIdx++;
}
// End note on last page if enough space
if (pg === totalPages - 1 && y > CB + 70) {
renderEndNote(p, y);
}
renderFooter(p, pg + 1, totalPages, data.export_id);
streams.push(p.join('\n'));
}
return assemblePDF(streams);
}
// ---------------------------------------------------------------------------
// PDF document assembly
// ---------------------------------------------------------------------------
function assemblePDF(pageStreams: string[]): Uint8Array {
const objContents: string[] = [];
// Obj 1: Catalog
objContents.push('<< /Type /Catalog /Pages 2 0 R >>');
// Obj 2: Pages (placeholder — patched after page objects are created)
objContents.push('');
// Obj 3–5: Built-in fonts
objContents.push('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>'); // /F1
objContents.push('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'); // /F2
objContents.push('<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>'); // /F3
const fontRes = '/F1 3 0 R /F2 4 0 R /F3 5 0 R';
// Content stream + page object for each page
const pageObjNums: number[] = [];
for (const stream of pageStreams) {
const csNum = objContents.length + 1;
objContents.push(`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`);
const pgNum = objContents.length + 1;
objContents.push(
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${PW} ${PH}] ` +
`/Contents ${csNum} 0 R /Resources << /Font << ${fontRes} >> >> >>`,
);
pageObjNums.push(pgNum);
}
// Patch Pages object
const kids = pageObjNums.map((n) => `${n} 0 R`).join(' ');
objContents[1] = `<< /Type /Pages /Kids [${kids}] /Count ${pageStreams.length} >>`;
// Assemble byte stream
const header = '%PDF-1.4\n';
const parts: string[] = [header];
const offsets: number[] = [];
let pos = header.length;
for (let i = 0; i < objContents.length; i++) {
offsets.push(pos);
const obj = `${i + 1} 0 obj\n${objContents[i]}\nendobj\n`;
parts.push(obj);
pos += obj.length;
}
// Cross-reference table
const xrefPos = pos;
const numEntries = objContents.length + 1;
parts.push(`xref\n0 ${numEntries}\n`);
parts.push('0000000000 65535 f \n');
for (const off of offsets) {
parts.push(`${String(off).padStart(10, '0')} 00000 n \n`);
}
// Trailer
parts.push(
`trailer\n<< /Size ${numEntries} /Root 1 0 R >>\nstartxref\n${xrefPos}\n%%EOF\n`,
);
return new TextEncoder().encode(parts.join(''));
}
+89
View File
@@ -0,0 +1,89 @@
/**
* RFC 3161 Timestamping Authority (TSA) client utilities.
*
* Constructs a minimal DER-encoded TimeStampReq and submits it to a
* public TSA endpoint. No ASN.1 library required — the 59-byte request
* is hand-assembled using the same DER-prefix pattern as the PKCS8
* wrapper in crypto.ts.
*/
/** Default public TSA endpoint (Sectigo / Comodo). */
export const DEFAULT_TSA_URL = 'http://timestamp.sectigo.com';
/**
* Build a minimal RFC 3161 TimeStampReq (DER-encoded, 59 bytes).
*
* Structure:
* SEQUENCE {
* INTEGER 1 -- version
* SEQUENCE { -- messageImprint
* SEQUENCE { -- hashAlgorithm (SHA-256)
* OID 2.16.840.1.101.3.4.2.1
* NULL
* }
* OCTET STRING <32 bytes> -- hashedMessage
* }
* BOOLEAN TRUE -- certReq
* }
*/
export function buildTimeStampReq(sha256Hash: Uint8Array): Uint8Array {
if (sha256Hash.length !== 32) {
throw new Error(`Expected 32-byte SHA-256 hash, got ${sha256Hash.length}`);
}
// DER prefix (24 bytes) — everything before the 32-byte hash
const prefix = new Uint8Array([
0x30, 0x39, // SEQUENCE (57 bytes)
0x02, 0x01, 0x01, // INTEGER 1 (version v1)
0x30, 0x31, // SEQUENCE (49 bytes) — MessageImprint
0x30, 0x0d, // SEQUENCE (13 bytes) — AlgorithmIdentifier
0x06, 0x09, // OID (9 bytes)
0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, // SHA-256 (2.16.840.1.101.3.4.2.1)
0x05, 0x00, // NULL (parameters)
0x04, 0x20, // OCTET STRING (32 bytes)
]);
// DER suffix (3 bytes) — certReq = TRUE
const suffix = new Uint8Array([0x01, 0x01, 0xff]);
// Assemble: prefix + hash + suffix = 24 + 32 + 3 = 59 bytes
const req = new Uint8Array(prefix.length + sha256Hash.length + suffix.length);
req.set(prefix);
req.set(sha256Hash, prefix.length);
req.set(suffix, prefix.length + sha256Hash.length);
return req;
}
/**
* Submit a SHA-256 hash to a TSA and return the raw DER TimeStampResp.
*
* @param sha256Hash - 32-byte SHA-256 digest to timestamp
* @param tsaUrl - TSA endpoint (defaults to Sectigo)
* @returns Raw DER-encoded TimeStampResp bytes
*/
export async function requestTimestamp(
sha256Hash: Uint8Array,
tsaUrl: string = DEFAULT_TSA_URL,
): Promise<Uint8Array> {
const body = buildTimeStampReq(sha256Hash);
const response = await fetch(tsaUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/timestamp-query',
},
body: body as unknown as BodyInit,
});
if (!response.ok) {
throw new Error(`TSA request failed: ${response.status} ${response.statusText}`);
}
const contentType = response.headers.get('Content-Type') ?? '';
if (!contentType.includes('application/timestamp-reply')) {
throw new Error(`Unexpected TSA response content-type: ${contentType}`);
}
return new Uint8Array(await response.arrayBuffer());
}
+68
View File
@@ -0,0 +1,68 @@
/**
* UUIDv7 generator conforming to RFC 9562.
*
* UUIDv7 embeds a Unix-millisecond timestamp in the most-significant 48 bits,
* making identifiers lexicographically sortable and eliminating the need for
* a separate created_at column in many cases.
*
* This implementation uses the Web Crypto API (crypto.getRandomValues) which
* is available natively in Node.js 20+.
*/
/**
* Generate a UUIDv7 string.
*
* Layout (128 bits total):
* bits 0-47 : Unix timestamp in milliseconds
* bits 48-51 : version (0b0111 = 7)
* bits 52-63 : random_a (12 bits)
* bits 64-65 : variant (0b10)
* bits 66-127: random_b (62 bits)
*/
export function generateUUIDv7(): string {
const now = Date.now();
const bytes = new Uint8Array(16);
// Fill with random bytes first
crypto.getRandomValues(bytes);
// Encode 48-bit timestamp (big-endian) into bytes 0..5
const high32 = Math.floor(now / 0x10000); // upper 32 bits of the 48-bit ts
const low16 = now & 0xffff; // lower 16 bits of the 48-bit ts
bytes[0] = (high32 >>> 24) & 0xff;
bytes[1] = (high32 >>> 16) & 0xff;
bytes[2] = (high32 >>> 8) & 0xff;
bytes[3] = high32 & 0xff;
bytes[4] = (low16 >>> 8) & 0xff;
bytes[5] = low16 & 0xff;
// Set version 7 (bits 48-51)
bytes[6] = (bytes[6]! & 0x0f) | 0x70;
// Set variant 10xx (bits 64-65)
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
return formatUUID(bytes);
}
/**
* Format a 16-byte array as a standard UUID string (8-4-4-4-12).
*/
function formatUUID(bytes: Uint8Array): string {
const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return (
hex.slice(0, 8) +
'-' +
hex.slice(8, 12) +
'-' +
hex.slice(12, 16) +
'-' +
hex.slice(16, 20) +
'-' +
hex.slice(20, 32)
);
}
+283
View File
@@ -0,0 +1,283 @@
/**
* BullMQ queue worker — processes operation, export, and TSA jobs.
*
* Run as a separate Node.js process: node dist/worker.js
*/
import 'dotenv/config';
import { Worker, type Job } from 'bullmq';
import { QUEUE_NAME } from './adapters/bullmq.js';
import { PostgresAdapter } from './adapters/postgres.js';
import { MinioAdapter } from './adapters/minio.js';
import { RedisCacheAdapter } from './adapters/redis-cache.js';
import type { Env } from './types.js';
import type { Operation } from './shared/index.js';
import { buildBrandedPDF } from './utils/pdf.js';
import { requestTimestamp, DEFAULT_TSA_URL } from './utils/tsa.js';
import { base64urlDecode, base64urlEncode } from './utils/crypto.js';
// ---------------------------------------------------------------------------
// Initialize adapters
// ---------------------------------------------------------------------------
const db = new PostgresAdapter(process.env.DATABASE_URL!);
const evidence = new MinioAdapter(
process.env.MINIO_ENDPOINT!,
process.env.MINIO_ACCESS_KEY!,
process.env.MINIO_SECRET_KEY!,
process.env.MINIO_BUCKET ?? 'elydora-evidence',
);
const cache = new RedisCacheAdapter(process.env.REDIS_URL!);
// Build env object for service compatibility
const env: Pick<Env, 'ELYDORA_DB' | 'ELYDORA_EVIDENCE' | 'ELYDORA_CACHE' | 'TSA_URL'> = {
ELYDORA_DB: db,
ELYDORA_EVIDENCE: evidence,
ELYDORA_CACHE: cache,
TSA_URL: process.env.TSA_URL,
};
// ---------------------------------------------------------------------------
// Message type
// ---------------------------------------------------------------------------
interface QueueMessageBody {
type: 'operation' | 'export' | 'tsa';
operation_id?: string;
org_id?: string;
agent_id?: string;
export_id?: string;
query_params?: string;
epoch_id?: string;
root_hash?: string;
r2_epoch_key?: string;
}
// ---------------------------------------------------------------------------
// Job processor
// ---------------------------------------------------------------------------
async function processJob(job: Job): Promise<void> {
const body = job.data as QueueMessageBody;
switch (body.type) {
case 'operation':
await processOperation(body);
break;
case 'export':
await processExport(body);
break;
case 'tsa':
await processTSA(body);
break;
default:
console.error(`Unknown queue message type: ${String((body as unknown as Record<string, unknown>).type)}`);
}
}
// ---------------------------------------------------------------------------
// Operation post-processing
// ---------------------------------------------------------------------------
async function processOperation(body: QueueMessageBody): Promise<void> {
if (!body.operation_id || !body.org_id) {
throw new Error('Operation queue message missing required fields.');
}
const operation = await env.ELYDORA_DB
.prepare('SELECT * FROM operations WHERE operation_id = ?')
.bind(body.operation_id)
.first<Operation>();
if (!operation) {
throw new Error(`Operation ${body.operation_id} not found in database.`);
}
const cacheKey = `chain:${operation.agent_id}:latest`;
await env.ELYDORA_CACHE.put(
cacheKey,
JSON.stringify({
chain_hash: operation.chain_hash,
seq_no: operation.seq_no,
}),
{ expirationTtl: 86400 },
);
}
// ---------------------------------------------------------------------------
// Export job processing
// ---------------------------------------------------------------------------
async function processExport(body: QueueMessageBody): Promise<void> {
if (!body.export_id || !body.org_id || !body.query_params) {
throw new Error('Export queue message missing required fields.');
}
const { export_id, org_id, query_params } = body;
await env.ELYDORA_DB
.prepare('UPDATE exports SET status = ? WHERE export_id = ?')
.bind('running', export_id)
.run();
try {
const params = JSON.parse(query_params) as {
start_time: number;
end_time: number;
agent_id: string | null;
operation_type: string | null;
format: 'json' | 'pdf';
};
const conditions: string[] = ['org_id = ?', 'created_at >= ?', 'created_at <= ?'];
const bindings: (string | number)[] = [org_id, params.start_time, params.end_time];
if (params.agent_id) {
conditions.push('agent_id = ?');
bindings.push(params.agent_id);
}
if (params.operation_type) {
conditions.push('operation_type = ?');
bindings.push(params.operation_type);
}
const whereClause = 'WHERE ' + conditions.join(' AND ');
const query = `SELECT * FROM operations ${whereClause} ORDER BY created_at ASC`;
const result = await env.ELYDORA_DB
.prepare(query)
.bind(...bindings)
.all<Operation>();
const operations = result.results ?? [];
let exportData: string | Uint8Array;
let contentType: string;
if (params.format === 'json') {
exportData = JSON.stringify(
{
export_id,
org_id,
generated_at: Date.now(),
query: {
start_time: params.start_time,
end_time: params.end_time,
agent_id: params.agent_id,
operation_type: params.operation_type,
},
total_operations: operations.length,
operations,
},
null,
2,
);
contentType = 'application/json';
} else {
const fmtDate = (ms: number) =>
new Date(ms).toISOString().slice(0, 16).replace('T', ' ');
exportData = buildBrandedPDF({
export_id,
org_id,
generated_at: new Date().toISOString(),
time_range: `${fmtDate(params.start_time)} to ${fmtDate(params.end_time)}`,
agent_filter: params.agent_id ?? 'All agents',
type_filter: params.operation_type ?? 'All types',
total: operations.length,
operations,
});
contentType = 'application/pdf';
}
const r2Key = `exports/${org_id}/${export_id}`;
await env.ELYDORA_EVIDENCE.put(r2Key, exportData, {
httpMetadata: { contentType },
customMetadata: { export_id, org_id },
});
const now = Date.now();
await env.ELYDORA_DB
.prepare(
'UPDATE exports SET status = ?, r2_export_key = ?, completed_at = ? WHERE export_id = ?',
)
.bind('done', r2Key, now, export_id)
.run();
} catch (error) {
const now = Date.now();
await env.ELYDORA_DB
.prepare('UPDATE exports SET status = ?, completed_at = ? WHERE export_id = ?')
.bind('failed', now, export_id)
.run();
throw error;
}
}
// ---------------------------------------------------------------------------
// TSA anchoring
// ---------------------------------------------------------------------------
async function processTSA(body: QueueMessageBody): Promise<void> {
if (!body.root_hash || !body.r2_epoch_key) {
throw new Error('TSA queue message missing required fields.');
}
const { root_hash, r2_epoch_key } = body;
const tsaUrl = env.TSA_URL ?? DEFAULT_TSA_URL;
const hashBytes = base64urlDecode(root_hash);
const tsaResponse = await requestTimestamp(hashBytes, tsaUrl);
const anchorData = {
tsa_token: base64urlEncode(tsaResponse),
tsa_url: tsaUrl,
anchored_at: Date.now(),
};
await env.ELYDORA_EVIDENCE.put(
`${r2_epoch_key}/tsa-anchor`,
JSON.stringify(anchorData),
{ httpMetadata: { contentType: 'application/json' } },
);
}
// ---------------------------------------------------------------------------
// Start the BullMQ worker
// ---------------------------------------------------------------------------
const redisUrl = new URL(process.env.REDIS_URL!);
const connection = {
host: redisUrl.hostname,
port: Number(redisUrl.port) || 6379,
};
await cache.connect();
const worker = new Worker(QUEUE_NAME, processJob, {
connection,
concurrency: 5,
});
console.log(`Elydora queue worker started (queue: ${QUEUE_NAME})`);
worker.on('completed', (job) => {
console.log(`Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
console.error(`Job ${job?.id} failed:`, err.message);
});
// Graceful shutdown
async function shutdown() {
console.log('Shutting down worker...');
await worker.close();
await cache.close();
await db.close();
process.exit(0);
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);