chore(oss): audit the server package and resync the SDK mirrors

Split the 657-line operation service into eor, submit, and verify
modules, add the AGENT_REVOKED and KEY_RETIRED codes the Backend already
emits, and condense every multi-line comment. Mirror Node, Python, and
Go at 2.1.0 with the deep-health 503, non-idempotent retry, rotation
grace, and path-encoding fixes. Document which routes and response
fields the self-hosted server does not provide.

Claude-Session: https://claude.ai/code/session_01Agoc9bRVE9KL7mmWtey9Vq
This commit is contained in:
雷电芽衣 committed 2026-09-03 20:48:20 -04:00
1 parent 2010175a82
commit bc6983a62e
364 files changed
+10180 -23349

No files matched your search

+1 -7
View File
@@ -1,10 +1,4 @@
/**
* 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.
*/
/** BullMQ queue adapter implementing the MessageQueue interface. */
import { Queue as BullQueue } from 'bullmq';
import type { ConnectionOptions } from 'bullmq';
+8 -35
View File
@@ -1,24 +1,12 @@
/**
* 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.
*/
/** Adapter interfaces for ElydoraOpenSource infrastructure services. */
// ---------------------------------------------------------------------------
// Database (replaces D1Database)
// ---------------------------------------------------------------------------
/**
* A prepared (and optionally bound) SQL statement.
* Mirrors D1PreparedStatement's interface exactly.
*/
/** A prepared (and optionally bound) SQL statement. */
export interface PreparedStatement {
/**
* Bind positional parameters (? placeholders) to the statement.
* Returns a new statement with the values set.
*/
/** Bind positional parameters (? placeholders) to the statement. */
bind(...values: unknown[]): PreparedStatement;
/** Execute and return the first row, or null if no rows match. */
@@ -31,18 +19,12 @@ export interface PreparedStatement {
run(): Promise<{ success: boolean }>;
}
/**
* Relational database adapter.
* Mirrors the subset of D1Database methods used by Elydora services.
*/
/** Relational database adapter. */
export interface Database {
/** Prepare a SQL statement for execution. */
prepare(sql: string): PreparedStatement;
/**
* Execute multiple prepared statements atomically (within a transaction).
* Mirrors D1Database.batch().
*/
/** Execute multiple prepared statements atomically (within a transaction). */
batch(statements: PreparedStatement[]): Promise<{ results: unknown[]; success: boolean }[]>;
}
@@ -73,10 +55,7 @@ export interface ObjectStoreHead {
readonly httpMetadata?: { readonly contentType?: string };
}
/**
* Object/blob storage adapter.
* Mirrors the subset of R2Bucket methods used by Elydora services.
*/
/** Object/blob storage adapter. */
export interface ObjectStore {
/** Upload an object to the store. */
put(
@@ -96,10 +75,7 @@ export interface ObjectStore {
// Cache (replaces KVNamespace)
// ---------------------------------------------------------------------------
/**
* Key/value cache adapter.
* Mirrors the subset of KVNamespace methods used by Elydora services.
*/
/** Key/value cache adapter. */
export interface Cache {
/** Retrieve a cached value, or null if not found / expired. */
get(key: string): Promise<string | null>;
@@ -112,10 +88,7 @@ export interface Cache {
// Message queue (replaces Queue)
// ---------------------------------------------------------------------------
/**
* Message queue adapter.
* Mirrors the subset of Cloudflare Queue methods used by Elydora services.
*/
/** Message queue adapter. */
export interface MessageQueue {
/** Enqueue a message under the caller's durable message ID. */
send(messageId: string, body: unknown): Promise<void>;
+2 -8
View File
@@ -1,10 +1,4 @@
/**
* 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.
*/
/** MinIO / S3-compatible object storage adapter implementing the ObjectStore interface. */
import {
S3Client,
@@ -64,7 +58,7 @@ export class MinioAdapter implements ObjectStore {
} else if (body instanceof Uint8Array) {
bodyData = body;
} else {
// Web ReadableStream — collect into buffer
// Web ReadableStream: collect into buffer
const chunks: Uint8Array[] = [];
const reader = body.getReader();
for (;;) {
+3 -18
View File
@@ -1,13 +1,4 @@
/**
* 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().
*/
/** PostgreSQL adapter implementing the Database interface. */
import pg from 'pg';
import type { Database, PreparedStatement } from './interfaces.js';
@@ -25,10 +16,7 @@ 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).
*/
/** Convert SQLite `?` positional placeholders to PostgreSQL `$N` style. */
function convertPlaceholders(sql: string): string {
let index = 0;
return sql.replace(/\?/g, () => `$${++index}`);
@@ -38,10 +26,7 @@ function convertPlaceholders(sql: string): string {
// Statement implementation
// ---------------------------------------------------------------------------
/**
* Internal PostgreSQL statement that implements PreparedStatement.
* Stores the SQL and bound values; executes lazily on first/all/run/executeWith.
*/
/** Internal PostgreSQL statement that implements PreparedStatement. */
class PostgresStatement implements PreparedStatement {
constructor(
private readonly pool: Pool,
+1 -6
View File
@@ -1,9 +1,4 @@
/**
* 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.
*/
/** Redis cache adapter implementing the Cache interface. */
import { Redis } from 'ioredis';
import type { Cache } from './interfaces.js';
+11 -27
View File
@@ -1,15 +1,4 @@
/**
* 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.
*/
/** Internationalization (i18n) translation map. */
export type Lang = 'en' | 'zh';
@@ -29,6 +18,14 @@ const messages: Record<string, Record<Lang, string>> = {
en: 'The signing key has been revoked.',
zh: '\u7b7e\u540d\u5bc6\u94a5\u5df2\u88ab\u64a4\u9500\u3002',
},
'error.keyRetired': {
en: 'The signing key has been retired.',
zh: '\u7b7e\u540d\u5bc6\u94a5\u5df2\u9000\u5f79\u3002',
},
'error.agentRevoked': {
en: 'The agent has been revoked.',
zh: '\u8be5\u4ee3\u7406\u5df2\u88ab\u540a\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',
@@ -363,15 +360,7 @@ const messages: Record<string, Record<Lang, string>> = {
},
};
/**
* 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.
*/
/** Retrieve a translated message by key. */
export function getMessage(
key: string,
lang: Lang = 'en',
@@ -386,12 +375,7 @@ export function getMessage(
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'.
*/
/** Detect the preferred language from an Accept-Language header value. */
export function detectLanguage(acceptLanguage: string | null | undefined): Lang {
if (!acceptLanguage) return 'en';
if (acceptLanguage.includes('zh')) return 'zh';
+2 -4
View File
@@ -1,6 +1,4 @@
/**
* Elydora API — Node.js entry point.
*/
/** Elydora API: Node.js entry point. */
import 'dotenv/config';
import { serve } from '@hono/node-server';
@@ -90,7 +88,7 @@ app.use('/*', async (c, next) => {
await next();
});
// CORS — always restrict to the configured allowlist
// CORS: always restrict to the configured allowlist
app.use(
'/*',
cors({
+3 -15
View File
@@ -1,16 +1,4 @@
/**
* Better Auth configuration for ElydoraOpenSource.
*
* Creates a Better Auth instance backed by PostgreSQL. The instance is
* configured to:
*
* - Use the existing `users` table via field-mapping (no schema change)
* - Support legacy PBKDF2-SHA256 password hashes alongside Better Auth's
* default scrypt so existing users can sign in without re-hashing
* - Expose bearer-token auth for API clients
* - Provide organization support with Elydora's 5-role RBAC hierarchy
* - Provide admin user-management endpoints
*/
/** Better Auth configuration for ElydoraOpenSource. */
import { betterAuth } from 'better-auth';
import { verifyPassword as verifyScryptPassword } from 'better-auth/crypto';
@@ -60,7 +48,7 @@ async function verifyLegacyPbkdf2(password: string, stored: string): Promise<boo
}
// ---------------------------------------------------------------------------
// RBAC access control — maps Elydora's 5-role hierarchy to Better Auth
// RBAC access control: maps Elydora's 5-role hierarchy to Better Auth
// ---------------------------------------------------------------------------
const elydoraAc = createAccessControl({
@@ -121,7 +109,7 @@ const readonlyInvestigatorRole = elydoraAc.newRole({
});
// ---------------------------------------------------------------------------
// Singleton — creates a Better Auth instance for the server process
// Singleton: creates a Better Auth instance for the server process
// ---------------------------------------------------------------------------
export function createAuth(databaseUrl: string, secret: string, baseUrl: string, allowedOrigins: string) {
+1 -8
View File
@@ -1,11 +1,4 @@
/**
* Authentication middleware for opaque API tokens and Better Auth sessions.
*
* A bearer value is resolved as a hashed API token first; anything else is
* validated as a Better Auth session (cookie or bearer session token).
* On success the org_id, role, actor, and credential kind are stored in the
* Hono context variables for downstream handlers.
*/
/** Authentication middleware for opaque API tokens and Better Auth sessions. */
import type { MiddlewareHandler } from 'hono';
import type { RbacRole } from '../shared/index.js';
+10 -52
View File
@@ -1,15 +1,4 @@
/**
* 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.
*/
/** Global error handler middleware. */
import type { ErrorHandler } from 'hono';
import type { ErrorCode, ErrorResponse } from '../shared/index.js';
@@ -18,18 +7,14 @@ 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.
*/
/** Maps each ErrorCode to its corresponding i18n message key. */
const ERROR_CODE_TO_I18N_KEY: Record<ErrorCode, string> = {
INVALID_SIGNATURE: 'error.invalidSignature',
UNKNOWN_AGENT: 'error.unknownAgent',
KEY_REVOKED: 'error.keyRevoked',
KEY_RETIRED: 'error.keyRetired',
AGENT_FROZEN: 'error.agentFrozen',
AGENT_REVOKED: 'error.agentRevoked',
TTL_EXPIRED: 'error.ttlExpired',
REPLAY_DETECTED: 'error.replayDetected',
PREV_HASH_MISMATCH: 'error.prevHashMismatch',
@@ -42,16 +27,7 @@ const ERROR_CODE_TO_I18N_KEY: Record<ErrorCode, string> = {
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)
*/
/** Custom error class that carries an HTTP status and Elydora error code. */
export class AppError extends Error {
public readonly statusCode: number;
public readonly errorCode: ErrorCode;
@@ -68,13 +44,13 @@ export class AppError extends Error {
details?: Record<string, unknown>,
) {
if (typeof messageOrOpts === 'object' && messageOrOpts !== null) {
// i18n mode — store the key; resolve later in the error handler
// 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
// Code-only mode: will be resolved via ERROR_CODE_TO_I18N_KEY
super(ERROR_CODES[errorCode]);
this.usesDefaultMessage = true;
} else {
@@ -88,15 +64,7 @@ export class AppError extends Error {
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.
*/
/** Resolve the user-facing message for a given language. */
resolveMessage(lang: Lang): string {
if (this.messageKey) {
return getMessage(this.messageKey, lang, this.messageParams);
@@ -111,12 +79,7 @@ export class AppError extends Error {
}
}
/**
* 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.
*/
/** Build a standardised error response body. */
export function buildErrorResponse(
code: ErrorCode,
requestId: string,
@@ -139,12 +102,7 @@ export function buildErrorResponse(
};
}
/**
* 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.
*/
/** Hono error handler that converts all errors into the ErrorResponse format. */
export const globalErrorHandler: ErrorHandler<{
Bindings: Env;
Variables: AppVariables;
+1 -8
View File
@@ -1,11 +1,4 @@
/**
* 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')`.
*/
/** i18n middleware: language detection from Accept-Language header. */
import type { MiddlewareHandler } from 'hono';
import type { Env, AppVariables } from '../types.js';
+4 -27
View File
@@ -1,25 +1,11 @@
/**
* 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.
*/
/** Role-based access control (RBAC) middleware. */
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.
*/
/** Privilege level map. */
const ROLE_LEVEL: Record<RbacRole, number> = {
readonly_investigator: 10,
integration_engineer: 20,
@@ -28,21 +14,12 @@ const ROLE_LEVEL: Record<RbacRole, number> = {
org_owner: 50,
};
/**
* Check if a role has at least the given privilege level.
*/
/** 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
*/
/** Create an RBAC middleware that requires a minimum role. */
export function requireRole(minimumRole: RbacRole): MiddlewareHandler<{
Bindings: Env;
Variables: AppVariables;
+1 -7
View File
@@ -1,10 +1,4 @@
/**
* 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.
*/
/** Request ID middleware. */
import type { MiddlewareHandler } from 'hono';
import type { Env, AppVariables } from '../types.js';
+1 -3
View File
@@ -1,6 +1,4 @@
/**
* Agent routes — registration, lookup, freezing, and key revocation.
*/
/** Agent routes: registration, lookup, freezing, and key revocation. */
import { Hono } from 'hono';
import type { Env, AppVariables } from '../types.js';
+2 -8
View File
@@ -1,10 +1,4 @@
/**
* 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.
*/
/** Audit routes: query the tamper-evident audit log. */
import { Hono } from 'hono';
import type { Env, AppVariables } from '../types.js';
@@ -19,7 +13,7 @@ const audit = new Hono<{ Bindings: Env; Variables: AppVariables }>();
audit.use('/*', authMiddleware);
// ---------------------------------------------------------------------------
// POST /v1/audit/query — Query the audit log
// POST /v1/audit/query: Query the audit log
// ---------------------------------------------------------------------------
audit.post(
'/query',
+7 -15
View File
@@ -1,12 +1,4 @@
/**
* Auth routes — registration, login, profile retrieval, and token refresh.
*
* /register and /login are public (no auth required).
* /me and /refresh require a valid Better Auth session or API token; /token requires a session.
*
* Uses Better Auth for session management while preserving the existing
* API surface (endpoint paths and response formats).
*/
/** Auth routes: registration, login, profile retrieval, and token refresh. */
import { Hono } from 'hono';
import type { Env, AppVariables } from '../types.js';
@@ -20,7 +12,7 @@ import { getMessage } from '../i18n/messages.js';
const auth = new Hono<{ Bindings: Env; Variables: AppVariables }>();
// ---------------------------------------------------------------------------
// POST /v1/auth/register — Create a new user and organization
// POST /v1/auth/register: Create a new user and organization
// ---------------------------------------------------------------------------
auth.post('/register', async (c) => {
const body = await c.req.json();
@@ -93,7 +85,7 @@ auth.post('/register', async (c) => {
});
// ---------------------------------------------------------------------------
// POST /v1/auth/login — Authenticate and receive a session token
// POST /v1/auth/login: Authenticate and receive a session token
// ---------------------------------------------------------------------------
auth.post('/login', async (c) => {
const body = await c.req.json();
@@ -134,7 +126,7 @@ auth.post('/login', async (c) => {
});
// ---------------------------------------------------------------------------
// GET /v1/auth/me — Get current user profile (auth required)
// GET /v1/auth/me: Get current user profile (auth required)
// ---------------------------------------------------------------------------
auth.get('/me', authMiddleware, async (c) => {
const userId = c.get('actor');
@@ -144,7 +136,7 @@ auth.get('/me', authMiddleware, async (c) => {
});
// ---------------------------------------------------------------------------
// POST /v1/auth/refresh — Issue a new session token (auth required)
// POST /v1/auth/refresh: Issue a new session token (auth required)
// ---------------------------------------------------------------------------
auth.post('/refresh', authMiddleware, async (c) => {
const betterAuthInstance = createAuth(
@@ -154,7 +146,7 @@ auth.post('/refresh', authMiddleware, async (c) => {
c.env.ALLOWED_ORIGINS,
);
// Get the current session and return its token — Better Auth handles
// Get the current session and return its token: Better Auth handles
// session refresh automatically based on the updateAge setting
const session = await betterAuthInstance.api.getSession({
headers: c.req.raw.headers,
@@ -171,7 +163,7 @@ auth.post('/refresh', authMiddleware, async (c) => {
});
// ---------------------------------------------------------------------------
// POST /v1/auth/token — Issue an opaque API token (session required)
// POST /v1/auth/token: Issue an opaque API token (session required)
// ---------------------------------------------------------------------------
auth.post('/token', authMiddleware, async (c) => {
if (c.get('auth_token_type') !== 'session') {
+3 -5
View File
@@ -1,6 +1,4 @@
/**
* Epoch routes — retrieve epoch root records with optional TSA anchors.
*/
/** Epoch routes: retrieve epoch root records with optional TSA anchors. */
import { Hono } from 'hono';
import type { Env, AppVariables } from '../types.js';
@@ -14,7 +12,7 @@ const epochs = new Hono<{ Bindings: Env; Variables: AppVariables }>();
epochs.use('/*', authMiddleware);
// ---------------------------------------------------------------------------
// GET /v1/epochs — List all epochs for the organization
// GET /v1/epochs: List all epochs for the organization
// ---------------------------------------------------------------------------
epochs.get('/', requireRole('readonly_investigator'), async (c) => {
const orgId = c.get('org_id');
@@ -24,7 +22,7 @@ epochs.get('/', requireRole('readonly_investigator'), async (c) => {
});
// ---------------------------------------------------------------------------
// GET /v1/epochs/:epoch_id — Retrieve an epoch root
// GET /v1/epochs/:epoch_id: Retrieve an epoch root
// ---------------------------------------------------------------------------
epochs.get(
'/:epoch_id',
+5 -7
View File
@@ -1,6 +1,4 @@
/**
* Export routes — create and retrieve compliance export jobs.
*/
/** Export routes: create and retrieve compliance export jobs. */
import { Hono } from 'hono';
import type { Env, AppVariables } from '../types.js';
@@ -16,7 +14,7 @@ const exports_ = new Hono<{ Bindings: Env; Variables: AppVariables }>();
exports_.use('/*', authMiddleware);
// ---------------------------------------------------------------------------
// GET /v1/exports — List all exports for the organization
// GET /v1/exports: List all exports for the organization
// ---------------------------------------------------------------------------
exports_.get('/', requireRole('compliance_auditor'), async (c) => {
const orgId = c.get('org_id');
@@ -26,7 +24,7 @@ exports_.get('/', requireRole('compliance_auditor'), async (c) => {
});
// ---------------------------------------------------------------------------
// POST /v1/exports — Create a compliance export job
// POST /v1/exports: Create a compliance export job
// ---------------------------------------------------------------------------
exports_.post(
'/',
@@ -55,7 +53,7 @@ exports_.post(
);
// ---------------------------------------------------------------------------
// GET /v1/exports/:export_id — Retrieve export status + download URL
// GET /v1/exports/:export_id: Retrieve export status + download URL
// ---------------------------------------------------------------------------
exports_.get(
'/:export_id',
@@ -77,7 +75,7 @@ exports_.get(
);
// ---------------------------------------------------------------------------
// GET /v1/exports/:export_id/download — Stream the export file from R2
// GET /v1/exports/:export_id/download: Stream the export file from R2
// ---------------------------------------------------------------------------
exports_.get(
'/:export_id/download',
+2 -11
View File
@@ -1,13 +1,4 @@
/**
* 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).
*/
/** JWKS route: public key discovery endpoint. */
import { Hono } from 'hono';
import type { Env, AppVariables } from '../types.js';
@@ -17,7 +8,7 @@ import { deriveEd25519PublicKey } from '../utils/crypto.js';
const jwks = new Hono<{ Bindings: Env; Variables: AppVariables }>();
// ---------------------------------------------------------------------------
// GET /.well-known/elydora/jwks.json — Retrieve the platform JWKS
// GET /.well-known/elydora/jwks.json: Retrieve the platform JWKS
// ---------------------------------------------------------------------------
jwks.get('/', async (c) => {
// Derive the public key from the server signing key
+4 -6
View File
@@ -1,6 +1,4 @@
/**
* Operation routes — submit, retrieve, and verify signed operation records.
*/
/** Operation routes: submit, retrieve, and verify signed operation records. */
import { Hono } from 'hono';
import type { Env, AppVariables } from '../types.js';
@@ -13,7 +11,7 @@ import * as operationService from '../services/operation-service.js';
const operations = new Hono<{ Bindings: Env; Variables: AppVariables }>();
// ---------------------------------------------------------------------------
// POST /v1/operations — Submit a signed EOR
// POST /v1/operations: Submit a signed EOR
// ---------------------------------------------------------------------------
// Operation submission uses the EOR's embedded signature for authentication
// rather than a Bearer token. The agent authenticates via its Ed25519 signature.
@@ -47,7 +45,7 @@ operations.post(
);
// ---------------------------------------------------------------------------
// GET /v1/operations/:operation_id — Retrieve an operation
// GET /v1/operations/:operation_id: Retrieve an operation
// ---------------------------------------------------------------------------
operations.get(
'/:operation_id',
@@ -65,7 +63,7 @@ operations.get(
);
// ---------------------------------------------------------------------------
// POST /v1/operations/:operation_id/verify — Verify operation integrity
// POST /v1/operations/:operation_id/verify: Verify operation integrity
// ---------------------------------------------------------------------------
operations.post(
'/:operation_id/verify',
+1 -5
View File
@@ -1,8 +1,4 @@
/**
* Epoch scheduler — runs every 5 minutes to create Merkle tree rollups.
*
* Run as a separate Node.js process: node dist/scheduler.js
*/
/** Epoch scheduler: runs every 5 minutes to create Merkle tree rollups. */
import 'dotenv/config';
import cron from 'node-cron';
@@ -1,9 +1,4 @@
/**
* 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.
*/
/** Agent service: business logic for agent lifecycle management. */
import type {
Agent,
@@ -1,6 +1,4 @@
/**
* Opaque API tokens: the raw value is returned once; only its SHA-256 hash is stored.
*/
/** Opaque API tokens: the raw value is returned once; only its SHA-256 hash is stored. */
import { createHash, randomBytes } from 'node:crypto';
import type { Database } from '../adapters/interfaces.js';
@@ -1,10 +1,4 @@
/**
* 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.
*/
/** Audit service: business logic for querying the tamper-evident audit log. */
import type { Operation, AuditQueryRequest, AuditQueryResponse } from '../shared/index.js';
import { DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT } from '../shared/index.js';
+1 -7
View File
@@ -1,10 +1,4 @@
/**
* Auth service — password hashing and user profile retrieval.
*
* Authentication is handled by Better Auth (see lib/auth.ts).
* This service provides password utilities for legacy PBKDF2 hash
* verification and the getMe profile query.
*/
/** Auth service: password hashing and user profile retrieval. */
import { AppError } from '../middleware/error-handler.js';
import type { RbacRole } from '../shared/index.js';
+3 -16
View File
@@ -1,11 +1,4 @@
/**
* 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.
*/
/** Epoch service: business logic for retrieving epoch root records. */
import type { Epoch, EER, GetEpochResponse, ListEpochsResponse } from '../shared/index.js';
import { AppError } from '../middleware/error-handler.js';
@@ -82,13 +75,7 @@ export async function getEpoch(
// 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.
*/
/** Create a new epoch by building a Merkle tree over operations in a time window, signing the Epoch Record, and p */
export async function createEpoch(
db: Database,
r2: ObjectStore,
@@ -97,7 +84,7 @@ export async function createEpoch(
startTime: number,
endTime: number,
): Promise<Epoch | null> {
// 1. Idempotency check — skip if epoch already exists for this window
// 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 = ?',
@@ -1,12 +1,4 @@
/**
* 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.
*/
/** Export service: business logic for compliance export job management. */
import type { Export, CreateExportRequest, CreateExportResponse, GetExportResponse, ListExportsResponse } from '../shared/index.js';
import { generateUUIDv7 } from '../utils/uuid.js';
@@ -0,0 +1,82 @@
import type { EOR } from '../shared/index.js';
import { MAX_PAYLOAD_SIZE, MAX_TTL_MS, MIN_TTL_MS, MAX_NONCE_LENGTH } from '../shared/index.js';
import { AppError } from '../middleware/error-handler.js';
/** Genesis chain hash for an agent's first operation. */
export const GENESIS_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
/** Server key ID carried in receipts. */
export const ELYDORA_KID = 'elydora-server-key-v1';
/** The EOR fields covered by the agent signature. */
export 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,
};
}
export function validateEORFields(eor: EOR, receivedAt: number): void {
if (eor.op_version !== '1.0') {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.unsupportedVersion', params: { version: eor.op_version } });
}
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 } });
}
}
if (eor.nonce.length > MAX_NONCE_LENGTH) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.nonceTooLong', params: { max: MAX_NONCE_LENGTH } });
}
if (typeof eor.issued_at !== 'number' || eor.issued_at <= 0) {
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.invalidIssuedAt' });
}
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 } });
}
if (eor.issued_at + eor.ttl_ms < receivedAt) {
throw new AppError(400, 'TTL_EXPIRED');
}
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');
}
}
}
@@ -1,328 +1,9 @@
/**
* 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 type { Operation, Receipt, GetOperationResponse } from '../shared/index.js';
import { AppError } from '../middleware/error-handler.js';
import type { Database, ObjectStore, Cache, MessageQueue, PreparedStatement } from '../adapters/interfaces.js';
import type { Database, ObjectStore } 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: Reserve the queue message ID; the send follows the commit
// ------------------------------------------------------------------
const queueMessageId = generateUUIDv7();
const receiptId = generateUUIDv7();
// ------------------------------------------------------------------
// 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),
);
try {
await db.batch(statements);
} catch (error) {
if (!isSeqNoConflict(error)) throw error;
const winner = await db
.prepare('SELECT chain_hash FROM operations WHERE agent_id = ? ORDER BY seq_no DESC LIMIT 1')
.bind(eor.agent_id)
.first<{ chain_hash: string }>();
if (!winner) throw new Error('seq_no conflict without a stored operation.');
throw new AppError(400, 'PREV_HASH_MISMATCH', {
key: 'operation.prevHashMismatch',
params: { expected: winner.chain_hash, actual: eor.prev_chain_hash },
});
}
try {
await queue.send(queueMessageId, {
type: 'operation',
operation_id: eor.operation_id,
org_id: eor.org_id,
agent_id: eor.agent_id,
});
} catch (error) {
console.error('operation.enqueue_failed', {
operation_id: eor.operation_id,
queue_message_id: queueMessageId,
error: error instanceof Error ? error.message : String(error),
});
}
return { receipt: ear };
}
function isSeqNoConflict(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false;
const { code, constraint } = error as { code?: unknown; constraint?: unknown };
return code === '23505' && constraint === 'operations_agent_id_seq_no_key';
}
// ---------------------------------------------------------------------------
// Get operation
// ---------------------------------------------------------------------------
export { submitOperation } from './operation-submit.js';
export { verifyOperation } from './operation-verify.js';
export async function getOperation(
db: Database,
@@ -354,7 +35,7 @@ export async function getOperation(
payload = (eor.payload as Record<string, unknown>) ?? undefined;
}
} catch {
// Payload fetch is best-effort — don't fail the request
// Payload fetch is best-effort: don't fail the request
}
}
@@ -368,290 +49,3 @@ export async function getOperation(
// ---------------------------------------------------------------------------
// 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,256 @@
import type { EOR, EAR, Agent, AgentKey, Operation } 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';
import { ELYDORA_KID, GENESIS_CHAIN_HASH, buildSignableEOR, validateEORFields } from './operation-eor.js';
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_REVOKED', { 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_RETIRED', { 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: Reserve the queue message ID; the send follows the commit
const queueMessageId = generateUUIDv7();
const receiptId = generateUUIDv7();
// 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),
);
try {
await db.batch(statements);
} catch (error) {
if (!isSeqNoConflict(error)) throw error;
const winner = await db
.prepare('SELECT chain_hash FROM operations WHERE agent_id = ? ORDER BY seq_no DESC LIMIT 1')
.bind(eor.agent_id)
.first<{ chain_hash: string }>();
if (!winner) throw new Error('seq_no conflict without a stored operation.');
throw new AppError(400, 'PREV_HASH_MISMATCH', {
key: 'operation.prevHashMismatch',
params: { expected: winner.chain_hash, actual: eor.prev_chain_hash },
});
}
try {
await queue.send(queueMessageId, {
type: 'operation',
operation_id: eor.operation_id,
org_id: eor.org_id,
agent_id: eor.agent_id,
});
} catch (error) {
console.error('operation.enqueue_failed', {
operation_id: eor.operation_id,
queue_message_id: queueMessageId,
error: error instanceof Error ? error.message : String(error),
});
}
return { receipt: ear };
}
function isSeqNoConflict(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false;
const { code, constraint } = error as { code?: unknown; constraint?: unknown };
return code === '23505' && constraint === 'operations_agent_id_seq_no_key';
}
@@ -0,0 +1,209 @@
import type { EOR, EAR, AgentKey, Operation, Receipt, VerifyOperationResponse } from '../shared/index.js';
import {
base64urlDecode,
verifyEd25519Signature,
jcsCanonicalise,
computeChainHash,
computeReceiptHash,
} from '../utils/crypto.js';
import { AppError } from '../middleware/error-handler.js';
import { getMerkleProof, verifyMerkleProof } from '../utils/merkle.js';
import type { Database, ObjectStore } from '../adapters/interfaces.js';
import { buildSignableEOR } from './operation-eor.js';
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 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,
};
}
@@ -5,7 +5,9 @@ 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.',
KEY_RETIRED: 'The signing key has been retired.',
AGENT_FROZEN: 'The agent is frozen and cannot submit operations.',
AGENT_REVOKED: 'The agent has been revoked.',
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.',
@@ -56,7 +56,9 @@ export type ErrorCode =
| 'INVALID_SIGNATURE'
| 'UNKNOWN_AGENT'
| 'KEY_REVOKED'
| 'KEY_RETIRED'
| 'AGENT_FROZEN'
| 'AGENT_REVOKED'
| 'TTL_EXPIRED'
| 'REPLAY_DETECTED'
| 'PREV_HASH_MISMATCH'
+4 -21
View File
@@ -1,9 +1,4 @@
/**
* 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.
*/
/** Elydora Operation Record (EOR) The fundamental unit of auditable activity in the Elydora protocol. */
export interface EOR {
/** Protocol version, always "1.0" */
readonly op_version: '1.0';
@@ -37,11 +32,7 @@ export interface EOR {
readonly signature: string;
}
/**
* Elydora Chain Hash (ECH)
*
* Represents the chain hash computation linking operations together.
*/
/** Elydora Chain Hash (ECH) Represents the chain hash computation linking operations together. */
export interface ECH {
/** Previous chain hash (base64url) */
readonly prev_ech: string;
@@ -55,11 +46,7 @@ export interface ECH {
readonly chain_hash: string;
}
/**
* Elydora Acknowledgment Receipt (EAR)
*
* Server-issued receipt confirming acceptance of an operation.
*/
/** Elydora Acknowledgment Receipt (EAR) Server-issued receipt confirming acceptance of an operation. */
export interface EAR {
/** Receipt version */
readonly receipt_version: string;
@@ -87,11 +74,7 @@ export interface EAR {
readonly elydora_signature: string;
}
/**
* Elydora Epoch Record (EER)
*
* Periodic Merkle-tree rollup anchoring operations to a root hash.
*/
/** Elydora Epoch Record (EER) Periodic Merkle-tree rollup anchoring operations to a root hash. */
export interface EER {
/** Unique epoch identifier */
readonly epoch_id: string;
+2 -10
View File
@@ -1,10 +1,4 @@
/**
* 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.
*/
/** Environment bindings for the ElydoraOpenSource Node.js server. */
import type { Database, ObjectStore, Cache, MessageQueue } from './adapters/interfaces.js';
@@ -46,9 +40,7 @@ export interface Env {
TSA_URL?: string;
}
/**
* Variables set by middleware and available via `c.get(...)` / `c.var`.
*/
/** 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;
+11 -71
View File
@@ -1,9 +1,4 @@
/**
* 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.
*/
/** Cryptographic utilities for the Elydora API. */
// ---------------------------------------------------------------------------
// Base64url helpers (RFC 4648 section 5)
@@ -52,11 +47,7 @@ export async function sha256Base64url(data: BufferSource | string): Promise<stri
// Ed25519 signature verification
// ---------------------------------------------------------------------------
/**
* Import a raw Ed25519 public key (32 bytes) as a CryptoKey.
*
* @param publicKeyBytes - 32-byte raw Ed25519 public key
*/
/** Import a raw Ed25519 public key (32 bytes) as a CryptoKey. */
export async function importEd25519PublicKey(publicKeyBytes: Uint8Array): Promise<CryptoKey> {
return crypto.subtle.importKey(
'raw',
@@ -67,14 +58,7 @@ export async function importEd25519PublicKey(publicKeyBytes: Uint8Array): Promis
);
}
/**
* 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
*/
/** Verify an Ed25519 signature over data. */
export async function verifyEd25519Signature(
publicKeyBytes: Uint8Array,
signatureBytes: Uint8Array,
@@ -97,15 +81,7 @@ export async function verifyEd25519Signature(
// 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> } }
* }
*/
/** PKCS8 DER prefix for wrapping a 32-byte Ed25519 seed into a valid PKCS8 key. */
const ED25519_PKCS8_PREFIX = new Uint8Array([
0x30, 0x2e, // SEQUENCE (46 bytes)
0x02, 0x01, 0x00, // INTEGER 0
@@ -123,14 +99,7 @@ function wrapSeedAsPkcs8(seed: Uint8Array): Uint8Array {
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
*/
/** Import a raw Ed25519 private key for signing. */
export async function importEd25519PrivateKey(privateKeyBase64url: string): Promise<CryptoKey> {
const seed = base64urlDecode(privateKeyBase64url);
const pkcs8 = wrapSeedAsPkcs8(seed);
@@ -144,13 +113,7 @@ export async function importEd25519PrivateKey(privateKeyBase64url: string): Prom
);
}
/**
* 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
*/
/** Sign data with an Ed25519 private key. */
export async function signEd25519(
privateKeyBase64url: string,
data: Uint8Array,
@@ -164,12 +127,7 @@ export async function signEd25519(
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 the public key from an Ed25519 private key (for JWKS). */
export async function deriveEd25519PublicKey(privateKeyBase64url: string): Promise<string> {
const seed = base64urlDecode(privateKeyBase64url);
const pkcs8 = wrapSeedAsPkcs8(seed);
@@ -178,7 +136,7 @@ export async function deriveEd25519PublicKey(privateKeyBase64url: string): Promi
'pkcs8',
pkcs8 as unknown as Uint8Array<ArrayBuffer>,
{ name: 'Ed25519' },
true, // extractable — needed to export the public key
true, // extractable: needed to export the public key
['sign'],
);
// Export as JWK to get the public 'x' parameter
@@ -191,15 +149,7 @@ export async function deriveEd25519PublicKey(privateKeyBase64url: string): Promi
// 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
*/
/** Canonicalize a JSON value according to JCS (RFC 8785). */
export function jcsCanonicalise(value: unknown): string {
if (value === null || value === undefined) {
return 'null';
@@ -237,13 +187,7 @@ export function jcsCanonicalise(value: unknown): string {
// 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 '|'.
*/
/** Compute the chain hash for an operation. */
export async function computeChainHash(
prevChainHash: string,
payloadHash: string,
@@ -258,11 +202,7 @@ export async function computeChainHash(
// Receipt hash computation
// ---------------------------------------------------------------------------
/**
* Compute the hash of an EAR receipt for signing.
*
* Hashes the canonicalized receipt fields (excluding the signature fields themselves).
*/
/** Compute the hash of an EAR receipt for signing. */
export async function computeReceiptHash(receiptFields: {
receipt_version: string;
receipt_id: string;
+5 -25
View File
@@ -1,10 +1,4 @@
/**
* 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.
*/
/** SHA-256 Merkle tree implementation for epoch rollups. */
import { sha256Base64url, base64urlDecode } from './crypto.js';
@@ -38,10 +32,7 @@ export interface MerkleProof {
// Internal helper
// ---------------------------------------------------------------------------
/**
* Compute the parent hash from two child hashes.
* Decodes both base64url hashes to raw bytes, concatenates, then SHA-256.
*/
/** Compute the parent hash from two child hashes. */
async function hashPair(left: string, right: string): Promise<string> {
const leftBytes = base64urlDecode(left);
const rightBytes = base64urlDecode(right);
@@ -55,12 +46,7 @@ async function hashPair(left: string, right: string): Promise<string> {
// 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.
*/
/** Build a Merkle tree from leaf hashes and their corresponding operation IDs. */
export async function buildMerkleTree(
leafHashes: string[],
operationIds: string[],
@@ -103,11 +89,7 @@ export async function buildMerkleTree(
// Proof generation
// ---------------------------------------------------------------------------
/**
* Generate a Merkle inclusion proof for a given leaf hash.
*
* Returns null if the leaf is not found in the tree.
*/
/** Generate a Merkle inclusion proof for a given leaf hash. */
export function getMerkleProof(
tree: MerkleTree,
leafHash: string,
@@ -142,9 +124,7 @@ export function getMerkleProof(
// Proof verification
// ---------------------------------------------------------------------------
/**
* Verify a Merkle inclusion proof by recomputing the root.
*/
/** Verify a Merkle inclusion proof by recomputing the root. */
export async function verifyMerkleProof(proof: MerkleProof): Promise<boolean> {
let current = proof.leaf;
+3 -20
View File
@@ -1,11 +1,4 @@
/**
* 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.
*/
/** Cursor-based pagination helpers. */
import { base64urlDecode, base64urlEncode } from './crypto.js';
@@ -17,24 +10,14 @@ export interface CursorPayload {
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
*/
/** Encode a cursor payload into an opaque 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
*/
/** Decode an opaque cursor string back into its payload. */
export function decodeCursor(cursor: string): CursorPayload | null {
try {
const bytes = base64urlDecode(cursor);
+7 -22
View File
@@ -1,15 +1,4 @@
/**
* 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.
*/
/** Branded PDF generator for Elydora compliance exports. */
// ---------------------------------------------------------------------------
// Types
@@ -46,15 +35,15 @@ 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
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
const CT = HDR_Y - 20; // 716: content top
const CB = FTR_LINE + 12; // 56 : content bottom
// Operations table
const ROW_H = 12; // row height
@@ -114,11 +103,7 @@ function txt(s: string, x: number, y: number, font: string, size: number, rgb: s
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.
*/
/** Draw the Elydora "E" logo mark. */
function drawLogo(x: number, y: number, size: number, rgb: string): string {
const sc = size / 24;
return [
@@ -131,7 +116,7 @@ function drawLogo(x: number, y: number, size: number, rgb: string): string {
}
// ---------------------------------------------------------------------------
// Section renderers — each appends drawing instructions to the page array
// Section renderers: each appends drawing instructions to the page array
// ---------------------------------------------------------------------------
/** Dark header bar with logo and brand text (every page). */
@@ -321,7 +306,7 @@ function assemblePDF(pageStreams: string[]): Uint8Array {
// Obj 1: Catalog
objContents.push('<< /Type /Catalog /Pages 2 0 R >>');
// Obj 2: Pages (placeholder — patched after page objects are created)
// Obj 2: Pages (placeholder: patched after page objects are created)
objContents.push('');
// Obj 3–5: Built-in fonts
+7 -35
View File
@@ -1,49 +1,27 @@
/**
* 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.
*/
/** RFC 3161 Timestamping Authority (TSA) client utilities. */
/** 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
* }
*/
/** Build a minimal RFC 3161 TimeStampReq (DER-encoded, 59 bytes). */
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
// 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
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
// DER suffix (3 bytes): certReq = TRUE
const suffix = new Uint8Array([0x01, 0x01, 0xff]);
// Assemble: prefix + hash + suffix = 24 + 32 + 3 = 59 bytes
@@ -55,13 +33,7 @@ export function buildTimeStampReq(sha256Hash: Uint8Array): Uint8Array {
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
*/
/** Submit a SHA-256 hash to a TSA and return the raw DER TimeStampResp. */
export async function requestTimestamp(
sha256Hash: Uint8Array,
tsaUrl: string = DEFAULT_TSA_URL,
+3 -23
View File
@@ -1,24 +1,6 @@
/**
* 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+.
*/
/** UUIDv7 generator conforming to RFC 9562. */
/**
* 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)
*/
/** Generate a UUIDv7 string. */
export function generateUUIDv7(): string {
const now = Date.now();
const bytes = new Uint8Array(16);
@@ -46,9 +28,7 @@ export function generateUUIDv7(): string {
return formatUUID(bytes);
}
/**
* Format a 16-byte array as a standard UUID string (8-4-4-4-12).
*/
/** 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'))
+1 -5
View File
@@ -1,8 +1,4 @@
/**
* BullMQ queue worker — processes operation, export, and TSA jobs.
*
* Run as a separate Node.js process: node dist/worker.js
*/
/** BullMQ queue worker: processes operation, export, and TSA jobs. */
import 'dotenv/config';
import { Worker, type Job } from 'bullmq';