Audit
This commit is contained in:
parent
34520a18ba
commit
01d0ff67f8
@ -47,6 +47,7 @@
|
||||
"@erp/factuges": "workspace:*",
|
||||
"@erp/suppliers": "workspace:*",
|
||||
"@repo/rdx-logger": "workspace:*",
|
||||
"@repo/rdx-audit": "workspace:*",
|
||||
"@repo/rdx-utils": "workspace:*",
|
||||
"bcrypt": "^6.0.0",
|
||||
"cls-rtracer": "^2.6.3",
|
||||
|
||||
@ -3,6 +3,8 @@ import express, { type Application } from "express";
|
||||
import helmet from "helmet";
|
||||
import responseTime from "response-time";
|
||||
|
||||
import { createOperationContextMiddleware, type IOperationContextStore } from "@erp/core/api";
|
||||
|
||||
import type { ConfigType } from "./config";
|
||||
|
||||
// ❗️ No cargamos dotenv aquí. Debe hacerse en el entrypoint o en ./config.
|
||||
@ -10,7 +12,10 @@ import type { ConfigType } from "./config";
|
||||
|
||||
import { logger } from "./lib/logger";
|
||||
|
||||
export function createApp(config: ConfigType): Application {
|
||||
export function createApp(
|
||||
config: ConfigType,
|
||||
operationContext: IOperationContextStore
|
||||
): Application {
|
||||
const app = express();
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
@ -28,6 +33,8 @@ export function createApp(config: ConfigType): Application {
|
||||
"Pagination-Count",
|
||||
"Pagination-Page",
|
||||
"Pagination-Limit",
|
||||
"X-Request-Id",
|
||||
"X-Correlation-Id",
|
||||
],
|
||||
};
|
||||
|
||||
@ -43,9 +50,14 @@ export function createApp(config: ConfigType): Application {
|
||||
"Pagination-Count",
|
||||
"Pagination-Page",
|
||||
"Pagination-Limit",
|
||||
"X-Request-Id",
|
||||
"X-Correlation-Id",
|
||||
],
|
||||
};
|
||||
|
||||
// Se instala antes de cualquier middleware que pueda necesitar sus identificadores.
|
||||
app.use(createOperationContextMiddleware(operationContext));
|
||||
|
||||
app.use(cors(config.flags.isDev ? devCors : prodCors));
|
||||
app.options("*", cors(config.flags.isDev ? devCors : prodCors));
|
||||
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
|
||||
import {
|
||||
AsyncLocalStorageOperationContextStore,
|
||||
OperationAuditContextProvider,
|
||||
SequelizeTransactionContext,
|
||||
} from "@erp/core/api";
|
||||
import { Audit } from "@repo/rdx-audit";
|
||||
import { DateTime } from "luxon";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@ -8,13 +14,23 @@ import { createApp } from "./app.ts";
|
||||
import { initConfig } from "./config";
|
||||
import { tryConnectToDatabase } from "./config/database.ts";
|
||||
import { registerHealthRoutes } from "./health.ts";
|
||||
import {
|
||||
AuditChangeModel,
|
||||
AuditEventModel,
|
||||
SequelizeAuditStore,
|
||||
auditModelInitializers,
|
||||
} from "./infrastructure/audit/sequelize";
|
||||
import { listRoutes, logger } from "./lib/index.ts";
|
||||
import { initModules } from "./lib/modules/index.ts";
|
||||
import { initModules, registerModels } from "./lib/modules/index.ts";
|
||||
import { registerModules } from "./register-modules.ts";
|
||||
|
||||
z.config(z.locales.es());
|
||||
|
||||
const config = initConfig(logger);
|
||||
const operationContext = new AsyncLocalStorageOperationContextStore();
|
||||
const transactionContext = new SequelizeTransactionContext();
|
||||
const auditContextProvider = new OperationAuditContextProvider(operationContext);
|
||||
const AUDIT_SOURCE = { type: "application", id: "uecko-erp" } as const;
|
||||
|
||||
// Guardamos información del estado del servidor
|
||||
export const currentState = {
|
||||
@ -118,7 +134,7 @@ const serverConnection = (conn: any) => {
|
||||
// Cargar paquetes de la aplicación: customers, invoices, routes...
|
||||
registerModules();
|
||||
|
||||
const app = createApp(config);
|
||||
const app = createApp(config, operationContext);
|
||||
|
||||
// Crea el servidor HTTP
|
||||
const server = http.createServer(app);
|
||||
@ -217,12 +233,29 @@ process.on("uncaughtException", async (error: Error) => {
|
||||
|
||||
// ➕ Rutas de salud disponibles desde el inicio del proceso
|
||||
registerHealthRoutes(app, config.server.apiBasePath, () => ({ ready: isReady }));
|
||||
registerModels(auditModelInitializers, undefined, { moduleName: "server-infrastructure" });
|
||||
const auditStore = new SequelizeAuditStore(
|
||||
AuditEventModel,
|
||||
AuditChangeModel,
|
||||
transactionContext
|
||||
);
|
||||
const audit = new Audit({
|
||||
source: AUDIT_SOURCE,
|
||||
contextProvider: auditContextProvider,
|
||||
store: auditStore,
|
||||
});
|
||||
|
||||
await initModules({
|
||||
config,
|
||||
app,
|
||||
database,
|
||||
logger,
|
||||
operationContext,
|
||||
transactionContext,
|
||||
audit,
|
||||
getService: () => {
|
||||
throw new Error("Services are only available from a module-scoped context.");
|
||||
},
|
||||
});
|
||||
|
||||
// El servidor ya está listo para recibir tráfico
|
||||
|
||||
@ -0,0 +1,81 @@
|
||||
import {
|
||||
DataTypes,
|
||||
type InferAttributes,
|
||||
type InferCreationAttributes,
|
||||
Model,
|
||||
type Sequelize,
|
||||
} from "sequelize";
|
||||
|
||||
import { AuditEventModel } from "./audit-event.model";
|
||||
|
||||
/** Cambios ordenados de una mutación; no tienen identificador global propio. */
|
||||
export class AuditChangeModel extends Model<
|
||||
InferAttributes<AuditChangeModel>,
|
||||
InferCreationAttributes<AuditChangeModel>
|
||||
> {
|
||||
declare audit_event_id: string;
|
||||
declare position: number;
|
||||
declare path: string;
|
||||
declare kind: string;
|
||||
declare before_value: unknown | null;
|
||||
declare after_value: unknown | null;
|
||||
|
||||
static associate(_database: Sequelize): void {
|
||||
AuditChangeModel.belongsTo(AuditEventModel, {
|
||||
as: "event",
|
||||
foreignKey: "audit_event_id",
|
||||
targetKey: "id",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeAuditChangeModel(database: Sequelize): typeof AuditChangeModel {
|
||||
AuditChangeModel.init(
|
||||
{
|
||||
audit_event_id: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
primaryKey: true,
|
||||
references: { model: "audit_events", key: "id" },
|
||||
onUpdate: "RESTRICT",
|
||||
onDelete: "CASCADE",
|
||||
},
|
||||
position: { type: DataTypes.INTEGER.UNSIGNED, allowNull: false, primaryKey: true },
|
||||
path: { type: DataTypes.STRING(255), allowNull: false },
|
||||
kind: { type: DataTypes.STRING(32), allowNull: false },
|
||||
before_value: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
// MariaDB devuelve JSON como texto; el modelo mantiene el contrato JSON al leerlo.
|
||||
get(this: AuditChangeModel): unknown | null {
|
||||
return deserializeJsonValue(this.getDataValue("before_value"));
|
||||
},
|
||||
},
|
||||
after_value: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
// MariaDB devuelve JSON como texto; el modelo mantiene el contrato JSON al leerlo.
|
||||
get(this: AuditChangeModel): unknown | null {
|
||||
return deserializeJsonValue(this.getDataValue("after_value"));
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize: database,
|
||||
modelName: "AuditChangeModel",
|
||||
tableName: "audit_changes",
|
||||
charset: "utf8mb4",
|
||||
collate: "utf8mb4_unicode_ci",
|
||||
underscored: true,
|
||||
timestamps: false,
|
||||
}
|
||||
);
|
||||
|
||||
return AuditChangeModel;
|
||||
}
|
||||
|
||||
function deserializeJsonValue(value: unknown): unknown | null {
|
||||
return typeof value === "string" ? JSON.parse(value) : value;
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
import {
|
||||
DataTypes,
|
||||
type InferAttributes,
|
||||
type InferCreationAttributes,
|
||||
Model,
|
||||
type Sequelize,
|
||||
} from "sequelize";
|
||||
|
||||
import { AuditChangeModel } from "./audit-change.model";
|
||||
|
||||
/** Modelo append-only; las referencias externas se almacenan como identificadores opacos. */
|
||||
export class AuditEventModel extends Model<
|
||||
InferAttributes<AuditEventModel>,
|
||||
InferCreationAttributes<AuditEventModel>
|
||||
> {
|
||||
declare id: string;
|
||||
declare kind: string;
|
||||
declare occurred_at: Date;
|
||||
declare source_type: string;
|
||||
declare source_id: string;
|
||||
declare operation_id: string;
|
||||
declare correlation_id: string;
|
||||
declare origin: string;
|
||||
declare actor_type: string;
|
||||
declare actor_id: string | null;
|
||||
declare actor_label: string | null;
|
||||
declare scope_type: string | null;
|
||||
declare scope_id: string | null;
|
||||
declare scope_label: string | null;
|
||||
declare action: string;
|
||||
declare target_type: string;
|
||||
declare target_id: string | null;
|
||||
declare target_label: string | null;
|
||||
|
||||
static associate(_database: Sequelize): void {
|
||||
AuditEventModel.hasMany(AuditChangeModel, {
|
||||
as: "changes",
|
||||
foreignKey: "audit_event_id",
|
||||
sourceKey: "id",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeAuditEventModel(database: Sequelize): typeof AuditEventModel {
|
||||
AuditEventModel.init(
|
||||
{
|
||||
id: { type: DataTypes.STRING(64), allowNull: false, primaryKey: true },
|
||||
kind: { type: DataTypes.STRING(32), allowNull: false },
|
||||
occurred_at: { type: DataTypes.DATE(3), allowNull: false },
|
||||
source_type: { type: DataTypes.STRING(64), allowNull: false },
|
||||
source_id: { type: DataTypes.STRING(128), allowNull: false },
|
||||
operation_id: { type: DataTypes.STRING(128), allowNull: false },
|
||||
correlation_id: { type: DataTypes.STRING(128), allowNull: false },
|
||||
origin: { type: DataTypes.STRING(64), allowNull: false },
|
||||
actor_type: { type: DataTypes.STRING(64), allowNull: false },
|
||||
actor_id: { type: DataTypes.STRING(128), allowNull: true, defaultValue: null },
|
||||
actor_label: { type: DataTypes.STRING(255), allowNull: true, defaultValue: null },
|
||||
scope_type: { type: DataTypes.STRING(64), allowNull: true, defaultValue: null },
|
||||
scope_id: { type: DataTypes.STRING(128), allowNull: true, defaultValue: null },
|
||||
scope_label: { type: DataTypes.STRING(255), allowNull: true, defaultValue: null },
|
||||
action: { type: DataTypes.STRING(191), allowNull: false },
|
||||
target_type: { type: DataTypes.STRING(64), allowNull: false },
|
||||
target_id: { type: DataTypes.STRING(128), allowNull: true, defaultValue: null },
|
||||
target_label: { type: DataTypes.STRING(255), allowNull: true, defaultValue: null },
|
||||
},
|
||||
{
|
||||
sequelize: database,
|
||||
modelName: "AuditEventModel",
|
||||
tableName: "audit_events",
|
||||
charset: "utf8mb4",
|
||||
collate: "utf8mb4_unicode_ci",
|
||||
underscored: true,
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{ name: "idx_audit_events_occurred_at_id", fields: ["occurred_at", "id"] },
|
||||
{
|
||||
name: "idx_audit_events_scope_occurred_at_id",
|
||||
fields: ["scope_type", "scope_id", "occurred_at", "id"],
|
||||
},
|
||||
{
|
||||
name: "idx_audit_events_target_occurred_at_id",
|
||||
fields: ["target_type", "target_id", "occurred_at", "id"],
|
||||
},
|
||||
{
|
||||
name: "idx_audit_events_actor_occurred_at_id",
|
||||
fields: ["actor_type", "actor_id", "occurred_at", "id"],
|
||||
},
|
||||
{ name: "idx_audit_events_operation_id", fields: ["operation_id"] },
|
||||
{ name: "idx_audit_events_correlation_id", fields: ["correlation_id"] },
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
return AuditEventModel;
|
||||
}
|
||||
10
apps/server/src/infrastructure/audit/sequelize/index.ts
Normal file
10
apps/server/src/infrastructure/audit/sequelize/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export * from "./audit-change.model";
|
||||
export * from "./audit-event.model";
|
||||
export * from "./sequelize-audit-entry.mapper";
|
||||
export * from "./sequelize-audit-store";
|
||||
|
||||
import { initializeAuditChangeModel } from "./audit-change.model";
|
||||
import { initializeAuditEventModel } from "./audit-event.model";
|
||||
|
||||
/** Initializers globales, registrados una vez por el runtime antes de initModels(). */
|
||||
export const auditModelInitializers = [initializeAuditEventModel, initializeAuditChangeModel];
|
||||
@ -0,0 +1,68 @@
|
||||
import type { AuditEntry, AuditValue } from "@repo/rdx-audit";
|
||||
|
||||
export type AuditEventPersistence = {
|
||||
id: string;
|
||||
kind: "event" | "mutation";
|
||||
occurred_at: Date;
|
||||
source_type: string;
|
||||
source_id: string;
|
||||
operation_id: string;
|
||||
correlation_id: string;
|
||||
origin: string;
|
||||
actor_type: string;
|
||||
actor_id: string | null;
|
||||
actor_label: string | null;
|
||||
scope_type: string | null;
|
||||
scope_id: string | null;
|
||||
scope_label: string | null;
|
||||
action: string;
|
||||
target_type: string;
|
||||
target_id: string | null;
|
||||
target_label: string | null;
|
||||
};
|
||||
|
||||
export type AuditChangePersistence = {
|
||||
audit_event_id: string;
|
||||
position: number;
|
||||
path: string;
|
||||
kind: "set" | "unset" | "change";
|
||||
before_value: AuditValue | null;
|
||||
after_value: AuditValue | null;
|
||||
};
|
||||
|
||||
/** Convierte el contrato portable en filas SQL sin exponer nombres de columnas al dominio. */
|
||||
export function mapAuditEntry(entry: AuditEntry): {
|
||||
event: AuditEventPersistence;
|
||||
changes: AuditChangePersistence[];
|
||||
} {
|
||||
return {
|
||||
event: {
|
||||
id: entry.id,
|
||||
kind: entry.kind,
|
||||
occurred_at: new Date(entry.occurredAt),
|
||||
source_type: entry.source.type,
|
||||
source_id: entry.source.id,
|
||||
operation_id: entry.operation.id,
|
||||
correlation_id: entry.operation.correlationId,
|
||||
origin: entry.operation.origin,
|
||||
actor_type: entry.actor.type,
|
||||
actor_id: entry.actor.id,
|
||||
actor_label: entry.actor.label,
|
||||
scope_type: entry.scope?.type ?? null,
|
||||
scope_id: entry.scope?.id ?? null,
|
||||
scope_label: entry.scope?.label ?? null,
|
||||
action: entry.action,
|
||||
target_type: entry.target.type,
|
||||
target_id: entry.target.id,
|
||||
target_label: entry.target.label,
|
||||
},
|
||||
changes: entry.changes.map((change, position) => ({
|
||||
audit_event_id: entry.id,
|
||||
position,
|
||||
path: change.path,
|
||||
kind: change.kind,
|
||||
before_value: change.before,
|
||||
after_value: change.after,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,136 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { ISequelizeTransactionContext } from "@erp/core/api";
|
||||
import type { AuditEntry } from "@repo/rdx-audit";
|
||||
import { AuditStoreError } from "@repo/rdx-audit";
|
||||
import type { Transaction } from "sequelize";
|
||||
|
||||
import type { AuditChangeModel } from "./audit-change.model";
|
||||
import type { AuditEventModel } from "./audit-event.model";
|
||||
import { SequelizeAuditStore } from "./sequelize-audit-store";
|
||||
|
||||
const eventEntry: AuditEntry = {
|
||||
id: "event-1",
|
||||
kind: "event",
|
||||
occurredAt: "2026-08-24T10:20:30.123Z",
|
||||
source: { type: "server", id: "server-1" },
|
||||
operation: { id: "operation-1", correlationId: "correlation-1", origin: "http" },
|
||||
actor: { type: "anonymous", id: null, label: null },
|
||||
scope: null,
|
||||
action: "authentication.failed",
|
||||
target: { type: "session", id: null, label: null },
|
||||
changes: [],
|
||||
};
|
||||
|
||||
const mutationEntry: AuditEntry = {
|
||||
...eventEntry,
|
||||
id: "mutation-1",
|
||||
kind: "mutation",
|
||||
action: "customer.updated",
|
||||
actor: { type: "user", id: "user-1", label: "user@example.com" },
|
||||
scope: { type: "company", id: "company-1", label: null },
|
||||
target: { type: "customer", id: "customer-1", label: "Acme" },
|
||||
changes: [
|
||||
{ path: "name", kind: "change", before: "Before", after: "After" },
|
||||
{ path: "active", kind: "set", before: null, after: true },
|
||||
],
|
||||
};
|
||||
|
||||
function buildStore(transaction: Transaction | null = null) {
|
||||
const createdEvents: unknown[] = [];
|
||||
const createdChanges: unknown[] = [];
|
||||
const eventOptions: unknown[] = [];
|
||||
const changeOptions: unknown[] = [];
|
||||
|
||||
const eventModel = {
|
||||
create: async (attributes: unknown, options: unknown) => {
|
||||
createdEvents.push(attributes);
|
||||
eventOptions.push(options);
|
||||
},
|
||||
} as unknown as typeof AuditEventModel;
|
||||
const changeModel = {
|
||||
bulkCreate: async (attributes: unknown, options: unknown) => {
|
||||
createdChanges.push(attributes);
|
||||
changeOptions.push(options);
|
||||
},
|
||||
} as unknown as typeof AuditChangeModel;
|
||||
const transactionContext = {
|
||||
current: () => transaction,
|
||||
} as unknown as ISequelizeTransactionContext;
|
||||
|
||||
return {
|
||||
store: new SequelizeAuditStore(eventModel, changeModel, transactionContext),
|
||||
createdEvents,
|
||||
createdChanges,
|
||||
eventOptions,
|
||||
changeOptions,
|
||||
};
|
||||
}
|
||||
|
||||
test("SequelizeAuditStore persiste un evento sin transacción y mapea sus campos", async () => {
|
||||
const fixture = buildStore();
|
||||
|
||||
const result = await fixture.store.append(eventEntry);
|
||||
|
||||
assert.equal(result.isSuccess, true);
|
||||
assert.equal(fixture.createdEvents.length, 1);
|
||||
assert.equal(fixture.createdChanges.length, 0);
|
||||
assert.deepEqual(fixture.createdEvents[0], {
|
||||
id: "event-1",
|
||||
kind: "event",
|
||||
occurred_at: new Date("2026-08-24T10:20:30.123Z"),
|
||||
source_type: "server",
|
||||
source_id: "server-1",
|
||||
operation_id: "operation-1",
|
||||
correlation_id: "correlation-1",
|
||||
origin: "http",
|
||||
actor_type: "anonymous",
|
||||
actor_id: null,
|
||||
actor_label: null,
|
||||
scope_type: null,
|
||||
scope_id: null,
|
||||
scope_label: null,
|
||||
action: "authentication.failed",
|
||||
target_type: "session",
|
||||
target_id: null,
|
||||
target_label: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("SequelizeAuditStore exige transacción para mutaciones y reutiliza la activa", async () => {
|
||||
const withoutTransaction = buildStore();
|
||||
const rejected = await withoutTransaction.store.append(mutationEntry);
|
||||
|
||||
assert.equal(rejected.isFailure, true);
|
||||
assert.ok(rejected.error instanceof AuditStoreError);
|
||||
assert.equal(withoutTransaction.createdEvents.length, 0);
|
||||
|
||||
const transaction = {} as Transaction;
|
||||
const withinTransaction = buildStore(transaction);
|
||||
const appended = await withinTransaction.store.append(mutationEntry);
|
||||
|
||||
assert.equal(appended.isSuccess, true);
|
||||
assert.equal(withinTransaction.createdEvents.length, 1);
|
||||
assert.equal(withinTransaction.createdChanges.length, 1);
|
||||
assert.deepEqual(withinTransaction.eventOptions[0], { transaction });
|
||||
assert.deepEqual(withinTransaction.changeOptions[0], { transaction });
|
||||
assert.deepEqual(withinTransaction.createdChanges[0], [
|
||||
{
|
||||
audit_event_id: "mutation-1",
|
||||
position: 0,
|
||||
path: "name",
|
||||
kind: "change",
|
||||
before_value: "Before",
|
||||
after_value: "After",
|
||||
},
|
||||
{
|
||||
audit_event_id: "mutation-1",
|
||||
position: 1,
|
||||
path: "active",
|
||||
kind: "set",
|
||||
before_value: null,
|
||||
after_value: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
@ -0,0 +1,43 @@
|
||||
import type { ISequelizeTransactionContext } from "@erp/core/api";
|
||||
import type { AuditEntry, IAuditStore } from "@repo/rdx-audit";
|
||||
import { AuditStoreError } from "@repo/rdx-audit";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import type { ModelStatic } from "sequelize";
|
||||
|
||||
import type { AuditChangeModel } from "./audit-change.model";
|
||||
import type { AuditEventModel } from "./audit-event.model";
|
||||
import { mapAuditEntry } from "./sequelize-audit-entry.mapper";
|
||||
|
||||
/**
|
||||
* Store append-only. Una mutación exige transacción activa para compartir el
|
||||
* rollback con la operación funcional; un evento aislado puede insertarse solo.
|
||||
*/
|
||||
export class SequelizeAuditStore implements IAuditStore {
|
||||
constructor(
|
||||
private readonly eventModel: ModelStatic<AuditEventModel>,
|
||||
private readonly changeModel: ModelStatic<AuditChangeModel>,
|
||||
private readonly transactionContext: ISequelizeTransactionContext
|
||||
) {}
|
||||
|
||||
async append(entry: AuditEntry): Promise<Result<void, AuditStoreError>> {
|
||||
const transaction = this.transactionContext.current();
|
||||
if (entry.kind === "mutation" && !transaction) {
|
||||
return Result.fail(
|
||||
new AuditStoreError("An active transaction is required for audit mutations.")
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const { event, changes } = mapAuditEntry(entry);
|
||||
await this.eventModel.create(event, { transaction: transaction ?? undefined });
|
||||
|
||||
if (entry.kind === "mutation") {
|
||||
await this.changeModel.bulkCreate(changes, { transaction });
|
||||
}
|
||||
|
||||
return Result.ok();
|
||||
} catch {
|
||||
return Result.fail(new AuditStoreError("Unable to append audit entry."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
// apps/server/src/lib/modules/model-loader.ts
|
||||
import type { ModuleParams } from "@erp/core/api";
|
||||
import type { Sequelize } from "sequelize";
|
||||
|
||||
import { logger } from "../logger";
|
||||
|
||||
@ -10,10 +11,10 @@ type SequelizeLike = {
|
||||
|
||||
type ModelStatic = {
|
||||
name: string;
|
||||
associate?: (sequelize: SequelizeLike) => void;
|
||||
associate?: (sequelize: Sequelize) => void;
|
||||
};
|
||||
|
||||
export type ModelInitializer = (sequelize: SequelizeLike) => ModelStatic;
|
||||
export type ModelInitializer = (sequelize: Sequelize) => ModelStatic;
|
||||
|
||||
type InitializerEntry = { init: ModelInitializer; moduleName: string };
|
||||
|
||||
@ -92,7 +93,7 @@ const defineModel = (database: SequelizeLike, entry: InitializerEntry): void =>
|
||||
const { init, moduleName } = entry;
|
||||
|
||||
try {
|
||||
const model = init(database);
|
||||
const model = init(database as unknown as Sequelize);
|
||||
|
||||
assertValidModel(model);
|
||||
assertModelNameIsAvailable(model.name, moduleName);
|
||||
@ -147,7 +148,7 @@ const associateModel = (database: SequelizeLike, entry: RegisteredModelEntry): v
|
||||
}
|
||||
|
||||
try {
|
||||
model.associate(database);
|
||||
model.associate(database as unknown as Sequelize);
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { IModuleServer, ModuleParams } from "@erp/core/api";
|
||||
import {
|
||||
AsyncLocalStorageOperationContextStore,
|
||||
type IModuleServer,
|
||||
type ModuleParams,
|
||||
SequelizeTransactionContext,
|
||||
} from "@erp/core/api";
|
||||
import { Audit } from "@repo/rdx-audit";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { resetModelsRegistry } from "./model-loader";
|
||||
import { initModules, registerModule, resetModuleRegistry } from "./module-loader";
|
||||
@ -31,6 +38,13 @@ function buildModuleParams(): ModuleParams & {
|
||||
models: {},
|
||||
},
|
||||
logger: console,
|
||||
operationContext: new AsyncLocalStorageOperationContextStore(),
|
||||
transactionContext: new SequelizeTransactionContext(),
|
||||
audit: new Audit({
|
||||
source: { type: "test", id: "module-loader" },
|
||||
contextProvider: { get: () => null },
|
||||
store: { append: async () => Result.ok() },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
298
apps/server/src/testing/http/audit-runtime.integration.test.ts
Normal file
298
apps/server/src/testing/http/audit-runtime.integration.test.ts
Normal file
@ -0,0 +1,298 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import test from "node:test";
|
||||
|
||||
import { type IModuleServer, SequelizeTransactionManager } from "@erp/core/api";
|
||||
import { AuditContextUnavailableError } from "@repo/rdx-audit";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
import { AuditChangeModel, AuditEventModel } from "@/infrastructure/audit/sequelize";
|
||||
|
||||
import {
|
||||
requireIdentityAuthenticated,
|
||||
requireIdentityTenantMembership,
|
||||
} from "../../../../../modules/identity/src/api/infrastructure/express/middlewares/require-identity-middlewares.ts";
|
||||
|
||||
import {
|
||||
buildCompanyHeaders,
|
||||
createTestAccount,
|
||||
createTestCompany,
|
||||
createTestMembership,
|
||||
loginByApi,
|
||||
} from "./http-test-fixtures.ts";
|
||||
import { createHttpTestRuntime } from "./http-test-runtime.ts";
|
||||
|
||||
const auditProbeModule: IModuleServer = {
|
||||
name: "audit-probe",
|
||||
version: "1.0.0",
|
||||
dependencies: ["identity"],
|
||||
start: (params) => {
|
||||
params.app.post(
|
||||
"/api/v1/__test/audit/event",
|
||||
...requireIdentityTenantMembership(params),
|
||||
async (request: Request, response: Response) => {
|
||||
const result = await params.audit.recordEvent({
|
||||
action: "test.audit.event",
|
||||
target: {
|
||||
type: "test_resource",
|
||||
id:
|
||||
typeof request.query.resourceId === "string"
|
||||
? request.query.resourceId
|
||||
: "resource-1",
|
||||
label: "Test resource",
|
||||
},
|
||||
});
|
||||
response.status(result.isSuccess ? 201 : 500).json({ success: result.isSuccess });
|
||||
}
|
||||
);
|
||||
|
||||
params.app.post(
|
||||
"/api/v1/__test/audit/event-no-company",
|
||||
...requireIdentityAuthenticated(params),
|
||||
async (_request: Request, response: Response) => {
|
||||
const result = await params.audit.recordEvent({
|
||||
action: "test.audit.no_company",
|
||||
target: { type: "test_resource", id: "resource-no-company", label: "Test resource" },
|
||||
});
|
||||
response.status(result.isSuccess ? 201 : 500).json({ success: result.isSuccess });
|
||||
}
|
||||
);
|
||||
|
||||
params.app.post(
|
||||
"/api/v1/__test/audit/mutation",
|
||||
...requireIdentityTenantMembership(params),
|
||||
async (_request: Request, response: Response) => {
|
||||
const transactionManager = new SequelizeTransactionManager(
|
||||
params.database,
|
||||
params.transactionContext
|
||||
);
|
||||
const result = await transactionManager.complete(async () =>
|
||||
params.audit.recordMutation({
|
||||
action: "test.resource.updated",
|
||||
target: { type: "test_resource", id: "resource-1", label: "Test resource" },
|
||||
before: { name: "Before" },
|
||||
after: { name: "After" },
|
||||
})
|
||||
);
|
||||
response.status(result.isSuccess ? 201 : 500).json({ success: result.isSuccess });
|
||||
}
|
||||
);
|
||||
|
||||
params.app.post(
|
||||
"/api/v1/__test/audit/mutation-without-transaction",
|
||||
...requireIdentityTenantMembership(params),
|
||||
async (_request: Request, response: Response) => {
|
||||
const result = await params.audit.recordMutation({
|
||||
action: "test.resource.updated_without_transaction",
|
||||
target: { type: "test_resource", id: "resource-2", label: "Test resource" },
|
||||
before: { name: "Before" },
|
||||
after: { name: "After" },
|
||||
});
|
||||
response.status(result.isSuccess ? 201 : 409).json({ success: result.isSuccess });
|
||||
}
|
||||
);
|
||||
|
||||
params.app.post(
|
||||
"/api/v1/__test/audit/mutation-rollback",
|
||||
...requireIdentityTenantMembership(params),
|
||||
async (_request: Request, response: Response) => {
|
||||
const transactionManager = new SequelizeTransactionManager(
|
||||
params.database,
|
||||
params.transactionContext
|
||||
);
|
||||
try {
|
||||
await transactionManager.complete(async () => {
|
||||
const result = await params.audit.recordMutation({
|
||||
action: "test.resource.rollback",
|
||||
target: { type: "test_resource", id: "resource-rollback", label: "Test resource" },
|
||||
before: { name: "Before" },
|
||||
after: { name: "After" },
|
||||
});
|
||||
if (result.isFailure) return result;
|
||||
return Result.fail(new Error("test rollback"));
|
||||
});
|
||||
} catch {
|
||||
response.status(409).json({ success: false });
|
||||
return;
|
||||
}
|
||||
response.status(500).json({ success: false });
|
||||
}
|
||||
);
|
||||
|
||||
params.app.post(
|
||||
"/api/v1/__test/audit/mutation-noop",
|
||||
...requireIdentityTenantMembership(params),
|
||||
async (_request: Request, response: Response) => {
|
||||
const result = await params.audit.recordMutation({
|
||||
action: "test.resource.noop",
|
||||
target: { type: "test_resource", id: "resource-noop", label: "Test resource" },
|
||||
before: { name: "Same" },
|
||||
after: { name: "Same" },
|
||||
});
|
||||
response.status(result.isSuccess ? 201 : 500).json({ success: result.isSuccess });
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
test("Audit runtime propaga contexto HTTP y transacciones hasta MariaDB", async (t) => {
|
||||
const runtime = await createHttpTestRuntime({ testModules: [auditProbeModule] });
|
||||
t.after(async () => runtime.stop());
|
||||
|
||||
const first = await createAuthenticatedCompanyMember(runtime);
|
||||
const second = await createAuthenticatedCompanyMember(runtime);
|
||||
const correlationId = "audit-integration-test-123";
|
||||
|
||||
const eventResponse = await runtime.request("/api/v1/__test/audit/event", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...buildCompanyHeaders({ accessToken: first.accessToken, companyId: first.companyId }),
|
||||
"x-correlation-id": correlationId,
|
||||
},
|
||||
});
|
||||
assert.equal(eventResponse.status, 201);
|
||||
|
||||
const event = await AuditEventModel.findOne({ where: { action: "test.audit.event" } });
|
||||
assert.ok(event);
|
||||
assert.equal(event.source_type, "application");
|
||||
assert.equal(event.source_id, "uecko-erp");
|
||||
assert.equal(event.origin, "http");
|
||||
assert.equal(event.operation_id, eventResponse.headers.get("x-request-id"));
|
||||
assert.equal(event.correlation_id, correlationId);
|
||||
assert.equal(event.actor_type, "user");
|
||||
assert.equal(event.actor_id, first.accountId);
|
||||
assert.equal(event.scope_type, "company");
|
||||
assert.equal(event.scope_id, first.companyId);
|
||||
assert.equal(event.target_type, "test_resource");
|
||||
assert.equal(event.target_id, "resource-1");
|
||||
assert.equal(await AuditChangeModel.count({ where: { audit_event_id: event.id } }), 0);
|
||||
|
||||
const noCompanyResponse = await runtime.request("/api/v1/__test/audit/event-no-company", {
|
||||
method: "POST",
|
||||
headers: buildCompanyHeaders({ accessToken: first.accessToken }),
|
||||
});
|
||||
assert.equal(noCompanyResponse.status, 201);
|
||||
const noCompanyEvent = await AuditEventModel.findOne({
|
||||
where: { action: "test.audit.no_company" },
|
||||
});
|
||||
assert.ok(noCompanyEvent);
|
||||
assert.equal(noCompanyEvent.scope_type, null);
|
||||
assert.equal(noCompanyEvent.scope_id, null);
|
||||
|
||||
const mutationResponse = await runtime.request("/api/v1/__test/audit/mutation", {
|
||||
method: "POST",
|
||||
headers: buildCompanyHeaders({ accessToken: first.accessToken, companyId: first.companyId }),
|
||||
});
|
||||
assert.equal(mutationResponse.status, 201);
|
||||
const mutation = await AuditEventModel.findOne({ where: { action: "test.resource.updated" } });
|
||||
assert.ok(mutation);
|
||||
assert.equal(mutation.kind, "mutation");
|
||||
const mutationChange = await AuditChangeModel.findOne({ where: { audit_event_id: mutation.id } });
|
||||
assert.ok(mutationChange);
|
||||
assert.equal(mutationChange.path, "name");
|
||||
assert.equal(mutationChange.kind, "change");
|
||||
assert.equal(mutationChange.before_value, "Before");
|
||||
assert.equal(mutationChange.after_value, "After");
|
||||
|
||||
const mutationWithoutTransaction = await runtime.request(
|
||||
"/api/v1/__test/audit/mutation-without-transaction",
|
||||
{
|
||||
method: "POST",
|
||||
headers: buildCompanyHeaders({ accessToken: first.accessToken, companyId: first.companyId }),
|
||||
}
|
||||
);
|
||||
assert.equal(mutationWithoutTransaction.status, 409);
|
||||
assert.equal(
|
||||
await AuditEventModel.count({ where: { action: "test.resource.updated_without_transaction" } }),
|
||||
0
|
||||
);
|
||||
|
||||
const rollbackResponse = await runtime.request("/api/v1/__test/audit/mutation-rollback", {
|
||||
method: "POST",
|
||||
headers: buildCompanyHeaders({ accessToken: first.accessToken, companyId: first.companyId }),
|
||||
});
|
||||
assert.equal(rollbackResponse.status, 409);
|
||||
assert.equal(await AuditEventModel.count({ where: { action: "test.resource.rollback" } }), 0);
|
||||
assert.equal(await AuditChangeModel.count(), 1);
|
||||
|
||||
const countBeforeNoop = await AuditEventModel.count();
|
||||
const noopResponse = await runtime.request("/api/v1/__test/audit/mutation-noop", {
|
||||
method: "POST",
|
||||
headers: buildCompanyHeaders({ accessToken: first.accessToken, companyId: first.companyId }),
|
||||
});
|
||||
assert.equal(noopResponse.status, 201);
|
||||
assert.equal(await AuditEventModel.count(), countBeforeNoop);
|
||||
|
||||
const absentContext = await runtime.audit.recordEvent({
|
||||
action: "test.audit.outside_context",
|
||||
target: { type: "test_resource", id: "outside", label: null },
|
||||
});
|
||||
assert.equal(absentContext.isFailure, true);
|
||||
assert.ok(absentContext.error instanceof AuditContextUnavailableError);
|
||||
|
||||
const [firstConcurrent, secondConcurrent] = await Promise.all([
|
||||
runtime.request("/api/v1/__test/audit/event?resourceId=concurrent-1", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...buildCompanyHeaders({ accessToken: first.accessToken, companyId: first.companyId }),
|
||||
"x-correlation-id": "concurrent-correlation-1",
|
||||
},
|
||||
}),
|
||||
runtime.request("/api/v1/__test/audit/event?resourceId=concurrent-2", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...buildCompanyHeaders({ accessToken: second.accessToken, companyId: second.companyId }),
|
||||
"x-correlation-id": "concurrent-correlation-2",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
assert.equal(firstConcurrent.status, 201);
|
||||
assert.equal(secondConcurrent.status, 201);
|
||||
|
||||
const concurrentEvents = await AuditEventModel.findAll({
|
||||
where: { action: "test.audit.event" },
|
||||
order: [["target_id", "ASC"]],
|
||||
});
|
||||
const firstConcurrentEvent = concurrentEvents.find((entry) => entry.target_id === "concurrent-1");
|
||||
const secondConcurrentEvent = concurrentEvents.find(
|
||||
(entry) => entry.target_id === "concurrent-2"
|
||||
);
|
||||
assert.equal(firstConcurrentEvent?.actor_id, first.accountId);
|
||||
assert.equal(firstConcurrentEvent?.scope_id, first.companyId);
|
||||
assert.equal(firstConcurrentEvent?.correlation_id, "concurrent-correlation-1");
|
||||
assert.equal(secondConcurrentEvent?.actor_id, second.accountId);
|
||||
assert.equal(secondConcurrentEvent?.scope_id, second.companyId);
|
||||
assert.equal(secondConcurrentEvent?.correlation_id, "concurrent-correlation-2");
|
||||
});
|
||||
|
||||
async function createAuthenticatedCompanyMember(
|
||||
runtime: Awaited<ReturnType<typeof createHttpTestRuntime>>
|
||||
) {
|
||||
const account = await createTestAccount(runtime.database, {
|
||||
email: `audit-${randomUUID()}@example.com`,
|
||||
password: "Secret123!",
|
||||
});
|
||||
const company = await createTestCompany(runtime.database, {
|
||||
legalName: `Audit Company ${randomUUID()}`,
|
||||
});
|
||||
await createTestMembership(runtime.database, {
|
||||
accountId: account.id,
|
||||
companyId: company.id,
|
||||
});
|
||||
const loginResponse = await loginByApi({
|
||||
request: runtime.request,
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
});
|
||||
assert.equal(loginResponse.status, 200);
|
||||
assert.equal(typeof loginResponse.body, "object");
|
||||
assert.notEqual(loginResponse.body, null);
|
||||
const loginBody = loginResponse.body as { access_token: string };
|
||||
|
||||
return {
|
||||
accountId: account.id,
|
||||
companyId: company.id,
|
||||
accessToken: loginBody.access_token,
|
||||
};
|
||||
}
|
||||
@ -3,6 +3,15 @@ import { type Server, createServer } from "node:http";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
AsyncLocalStorageOperationContextStore,
|
||||
type IModuleServer,
|
||||
type ISequelizeTransactionContext,
|
||||
OperationAuditContextProvider,
|
||||
SequelizeTransactionContext,
|
||||
} from "@erp/core/api";
|
||||
import type { IAudit } from "@repo/rdx-audit";
|
||||
import { Audit } from "@repo/rdx-audit";
|
||||
import dotenv from "dotenv";
|
||||
import mysql from "mysql2/promise";
|
||||
import type { Sequelize } from "sequelize";
|
||||
@ -13,9 +22,17 @@ import { closeDatabase, tryConnectToDatabase } from "@/config/database.ts";
|
||||
import type { ConfigType } from "@/config/index.ts";
|
||||
import { initConfig, resetConfigForTests } from "@/config/index.ts";
|
||||
import { registerHealthRoutes } from "@/health.ts";
|
||||
import {
|
||||
AuditChangeModel,
|
||||
AuditEventModel,
|
||||
SequelizeAuditStore,
|
||||
auditModelInitializers,
|
||||
} from "@/infrastructure/audit/sequelize";
|
||||
import { logger } from "@/lib/logger.ts";
|
||||
import {
|
||||
initModules,
|
||||
registerModels,
|
||||
registerModule,
|
||||
resetModelsRegistry,
|
||||
resetModuleRegistry,
|
||||
resetServiceRegistry,
|
||||
@ -25,11 +42,18 @@ import { registerModules } from "@/register-modules.ts";
|
||||
const currentDir = dirname(fileURLToPath(import.meta.url));
|
||||
const TEST_ENV_FILE = resolve(currentDir, "../../../.env.test");
|
||||
const FORBIDDEN_DATABASE_NAMES = new Set(["uecko_erp", "uecko_erp_sync", "rodax_db"]);
|
||||
const AUDIT_SOURCE = { type: "application", id: "uecko-erp" } as const;
|
||||
|
||||
export interface HttpTestRuntimeOptions {
|
||||
readonly testModules?: readonly IModuleServer[];
|
||||
}
|
||||
|
||||
export interface IHttpTestRuntime {
|
||||
readonly baseUrl: string;
|
||||
readonly config: ConfigType;
|
||||
readonly database: Sequelize;
|
||||
readonly transactionContext: ISequelizeTransactionContext;
|
||||
readonly audit: IAudit;
|
||||
request(
|
||||
path: string,
|
||||
init?: RequestInit
|
||||
@ -51,7 +75,9 @@ export interface IHttpTestRuntime {
|
||||
* - Sequelize real
|
||||
* - login real
|
||||
*/
|
||||
export async function createHttpTestRuntime(): Promise<IHttpTestRuntime> {
|
||||
export async function createHttpTestRuntime(
|
||||
options: HttpTestRuntimeOptions = {}
|
||||
): Promise<IHttpTestRuntime> {
|
||||
loadTestEnvironment();
|
||||
assignUniqueTestDatabaseName();
|
||||
resetServerRuntimeForTests();
|
||||
@ -63,18 +89,37 @@ export async function createHttpTestRuntime(): Promise<IHttpTestRuntime> {
|
||||
await recreateTestDatabase(config);
|
||||
|
||||
const database = await tryConnectToDatabase(config);
|
||||
const app = createApp(config);
|
||||
const operationContext = new AsyncLocalStorageOperationContextStore();
|
||||
const transactionContext = new SequelizeTransactionContext();
|
||||
const auditContextProvider = new OperationAuditContextProvider(operationContext);
|
||||
const auditStore = new SequelizeAuditStore(AuditEventModel, AuditChangeModel, transactionContext);
|
||||
const audit = new Audit({
|
||||
source: AUDIT_SOURCE,
|
||||
contextProvider: auditContextProvider,
|
||||
store: auditStore,
|
||||
});
|
||||
const app = createApp(config, operationContext);
|
||||
registerHealthRoutes(app, config.server.apiBasePath, () => ({ ready: true }));
|
||||
|
||||
let server: Server | null = null;
|
||||
|
||||
try {
|
||||
registerModules();
|
||||
for (const testModule of options.testModules ?? []) {
|
||||
registerModule(testModule);
|
||||
}
|
||||
registerModels(auditModelInitializers, undefined, { moduleName: "server-infrastructure" });
|
||||
await initModules({
|
||||
app,
|
||||
config,
|
||||
database,
|
||||
logger,
|
||||
operationContext,
|
||||
transactionContext,
|
||||
audit,
|
||||
getService: () => {
|
||||
throw new Error("Services are only available from a module-scoped context.");
|
||||
},
|
||||
});
|
||||
|
||||
server = createServer(app);
|
||||
@ -91,6 +136,8 @@ export async function createHttpTestRuntime(): Promise<IHttpTestRuntime> {
|
||||
baseUrl,
|
||||
config,
|
||||
database,
|
||||
transactionContext,
|
||||
audit,
|
||||
request: async (path: string, init?: RequestInit) => {
|
||||
const response = await fetch(`${baseUrl}${path}`, init);
|
||||
const contentType = response.headers.get("content-type") ?? "";
|
||||
|
||||
@ -0,0 +1,161 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { SequelizeTransactionManager } from "@erp/core/api";
|
||||
import type { AuditEntry, AuditMutationEntry } from "@repo/rdx-audit";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import {
|
||||
AuditChangeModel,
|
||||
AuditEventModel,
|
||||
SequelizeAuditStore,
|
||||
} from "@/infrastructure/audit/sequelize";
|
||||
import { createHttpTestRuntime } from "@/testing/http/http-test-runtime";
|
||||
|
||||
function createEvent(id: string): AuditEntry {
|
||||
return {
|
||||
id,
|
||||
kind: "event",
|
||||
occurredAt: "2026-08-24T10:20:30.123Z",
|
||||
source: { type: "server", id: "server-1" },
|
||||
operation: { id: "operation-1", correlationId: "correlation-1", origin: "http" },
|
||||
actor: { type: "anonymous", id: null, label: null },
|
||||
scope: null,
|
||||
action: "authentication.failed",
|
||||
target: { type: "session", id: null, label: null },
|
||||
changes: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createMutation(id: string): AuditMutationEntry {
|
||||
return {
|
||||
...createEvent(id),
|
||||
kind: "mutation",
|
||||
action: "customer.updated",
|
||||
actor: { type: "user", id: "user-1", label: "user@example.com" },
|
||||
scope: { type: "company", id: "company-1", label: null },
|
||||
target: { type: "customer", id: "customer-1", label: "Acme" },
|
||||
changes: [
|
||||
{ path: "name", kind: "change", before: "Before", after: "After" },
|
||||
{ path: "active", kind: "set", before: null, after: true },
|
||||
{ path: "metadata", kind: "change", before: { tier: 1 }, after: ["a", 2, false] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test("SequelizeAuditStore persiste y revierte entradas de auditoría en MariaDB", async (t) => {
|
||||
const runtime = await createHttpTestRuntime();
|
||||
t.after(async () => runtime.stop());
|
||||
|
||||
const store = new SequelizeAuditStore(
|
||||
AuditEventModel,
|
||||
AuditChangeModel,
|
||||
runtime.transactionContext
|
||||
);
|
||||
const transactionManager = new SequelizeTransactionManager(
|
||||
runtime.database,
|
||||
runtime.transactionContext
|
||||
);
|
||||
|
||||
const standaloneEvent = createEvent("event-standalone");
|
||||
const standaloneResult = await store.append(standaloneEvent);
|
||||
assert.equal(standaloneResult.isSuccess, true);
|
||||
assert.equal(await AuditEventModel.count({ where: { id: standaloneEvent.id } }), 1);
|
||||
assert.equal(await AuditChangeModel.count({ where: { audit_event_id: standaloneEvent.id } }), 0);
|
||||
|
||||
const mutationWithoutTransaction = await store.append(
|
||||
createMutation("mutation-without-transaction")
|
||||
);
|
||||
assert.equal(mutationWithoutTransaction.isFailure, true);
|
||||
assert.equal(await AuditEventModel.count({ where: { id: "mutation-without-transaction" } }), 0);
|
||||
|
||||
const committedMutation = createMutation("mutation-committed");
|
||||
await transactionManager.complete(async () => {
|
||||
const result = await store.append(committedMutation);
|
||||
assert.equal(result.isSuccess, true);
|
||||
return result;
|
||||
});
|
||||
assert.equal(await AuditEventModel.count({ where: { id: committedMutation.id } }), 1);
|
||||
assert.equal(
|
||||
await AuditChangeModel.count({ where: { audit_event_id: committedMutation.id } }),
|
||||
committedMutation.changes.length
|
||||
);
|
||||
|
||||
const persistedChanges = await AuditChangeModel.findAll({
|
||||
where: { audit_event_id: committedMutation.id },
|
||||
order: [["position", "ASC"]],
|
||||
});
|
||||
assert.deepEqual(
|
||||
persistedChanges.map((change) => change.position),
|
||||
[0, 1, 2]
|
||||
);
|
||||
assert.deepEqual(persistedChanges[2]?.before_value, { tier: 1 });
|
||||
assert.deepEqual(persistedChanges[2]?.after_value, ["a", 2, false]);
|
||||
|
||||
const persistedEvent = await AuditEventModel.findByPk(standaloneEvent.id);
|
||||
assert.equal(persistedEvent?.scope_type, null);
|
||||
assert.equal(persistedEvent?.actor_id, null);
|
||||
assert.equal(
|
||||
persistedEvent?.occurred_at.getTime(),
|
||||
new Date(standaloneEvent.occurredAt).getTime()
|
||||
);
|
||||
|
||||
const eventRolledBack = createEvent("event-rollback");
|
||||
await assert.rejects(
|
||||
() =>
|
||||
transactionManager.complete(async () => {
|
||||
const result = await store.append(eventRolledBack);
|
||||
assert.equal(result.isSuccess, true);
|
||||
return Result.fail(new Error("rollback event"));
|
||||
}),
|
||||
/rollback event/
|
||||
);
|
||||
assert.equal(await AuditEventModel.count({ where: { id: eventRolledBack.id } }), 0);
|
||||
|
||||
const mutationRolledBack = createMutation("mutation-rollback");
|
||||
await assert.rejects(
|
||||
() =>
|
||||
transactionManager.complete(async () => {
|
||||
const result = await store.append(mutationRolledBack);
|
||||
assert.equal(result.isSuccess, true);
|
||||
return Result.fail(new Error("rollback mutation"));
|
||||
}),
|
||||
/rollback mutation/
|
||||
);
|
||||
assert.equal(await AuditEventModel.count({ where: { id: mutationRolledBack.id } }), 0);
|
||||
assert.equal(
|
||||
await AuditChangeModel.count({ where: { audit_event_id: mutationRolledBack.id } }),
|
||||
0
|
||||
);
|
||||
|
||||
const mutationWithInvalidChange = {
|
||||
...createMutation("mutation-invalid-change"),
|
||||
changes: [
|
||||
{
|
||||
path: "x".repeat(256),
|
||||
kind: "change" as const,
|
||||
before: "Before",
|
||||
after: "After",
|
||||
},
|
||||
],
|
||||
} satisfies AuditEntry;
|
||||
await assert.rejects(
|
||||
() =>
|
||||
transactionManager.complete(async () => {
|
||||
const result = await store.append(mutationWithInvalidChange);
|
||||
assert.equal(result.isFailure, true);
|
||||
return result;
|
||||
}),
|
||||
/Unable to append audit entry/
|
||||
);
|
||||
assert.equal(await AuditEventModel.count({ where: { id: mutationWithInvalidChange.id } }), 0);
|
||||
assert.equal(
|
||||
await AuditChangeModel.count({ where: { audit_event_id: mutationWithInvalidChange.id } }),
|
||||
0
|
||||
);
|
||||
|
||||
const duplicateEvent = createEvent("event-duplicate");
|
||||
assert.equal((await store.append(duplicateEvent)).isSuccess, true);
|
||||
const duplicateResult = await store.append(duplicateEvent);
|
||||
assert.equal(duplicateResult.isFailure, true);
|
||||
});
|
||||
769
docs/adit/Sistema-transversal-de-auditoría-erp.md
Normal file
769
docs/adit/Sistema-transversal-de-auditoría-erp.md
Normal file
@ -0,0 +1,769 @@
|
||||
**DOCUMENTO DE DISEÑO**
|
||||
|
||||
Sistema transversal de auditoría
|
||||
|
||||
**Backend ERP · Estado consolidado**
|
||||
|
||||
Versión de diseño: 1.0
|
||||
Fecha: 24 de agosto de 2026
|
||||
Estado: DISEÑO CERRADO PARA IMPLEMENTACIÓN
|
||||
|
||||
**Propósito
|
||||
**Definir una infraestructura de auditoría portable, append-only y fácil de integrar, capaz de registrar eventos y mutaciones con contexto de operación, preservar atomicidad con las transacciones Sequelize del ERP y soportar una futura pantalla administrativa con autorización por scopes o acceso global.
|
||||
|
||||
# 1\. Resumen ejecutivo
|
||||
|
||||
La auditoría se diseña como una capacidad técnica transversal, no como un módulo funcional del ERP. El núcleo portable vive en \`@repo/rdx-audit\`; el host configura una única instancia de \`Audit\` y la distribuye por \`ModuleParams\`, igual que \`database\` y \`logger\`.
|
||||
|
||||
- \`IAudit.record()\` es la operación canónica. \`recordEvent()\` y \`recordMutation()\` son atajos tipados sin lógica alternativa.
|
||||
- \`@repo/rdx-audit\` no depende de Company, tenant, Express, Sequelize, Criteria, roles, permisos ni del \`OperationContext\` concreto del ERP.
|
||||
- El ámbito de un hecho se modela como \`scope: { type, id, label } | null\`. \`scope=null\` significa evento sin ámbito, nunca acceso global.
|
||||
- La autorización de lectura es responsabilidad de la aplicación host y queda separada del scope almacenado en el evento.
|
||||
- Las mutaciones auditadas en producción deben persistirse en la misma transacción Sequelize que la modificación funcional.
|
||||
- \`SequelizeTransactionContext\` se introduce por DI y solo expone la transacción activa a infraestructura transversal; repositorios y casos de uso siguen recibiendo la transacción explícitamente.
|
||||
- El store portable solo conoce \`append(entry)\`. \`SequelizeAuditStore\`, \`FileAuditStore\` o un store externo son adapters intercambiables.
|
||||
- La lectura administrativa usa un \`SequelizeAuditReader\` server-side apoyado en \`@repo/rdx-criteria/server\`, con whitelist propia de filtros y una política de acceso separada.
|
||||
- No se usa Event Sourcing. La auditoría tampoco sustituye a logs, tracing, historial de versiones de documentos ni BusinessVersion.
|
||||
|
||||
| **Decisión** | **Resultado** |
|
||||
| ------------------------------- | -------------------------------------------------------------- |
|
||||
| Arquitectura | Paquete portable + adapters host-specific |
|
||||
| Persistencia productiva inicial | MariaDB/Sequelize, tablas \`audit_events\` y \`audit_changes\` |
|
||||
| Mutaciones | Atómicas con la transacción funcional |
|
||||
| Eventos | Pueden persistirse con o sin transacción activa según contexto |
|
||||
| Lectura | Reader server-side; no forma parte del core portable |
|
||||
| Autorización | Fuera de \`rdx-audit\`; puede ser scoped o unrestricted |
|
||||
| Adopción | Incremental por módulo y operación |
|
||||
|
||||
# 2\. Objetivos y alcance
|
||||
|
||||
Proporcionar un Audit Trail reutilizable para registrar quién o qué realizó una acción, en qué operación, dentro de qué ámbito, sobre qué recurso y qué cambió cuando exista una mutación.
|
||||
|
||||
## 2.1 Objetivos específicos
|
||||
|
||||
- Integración mínima en cualquier módulo mediante \`IAudit\`.
|
||||
- Persistencia append-only e identidad propia de cada \`AuditEntry\`.
|
||||
- Modelo portable y serializable compatible con múltiples stores.
|
||||
- Diff determinista para mutaciones a partir de \`before\` y \`after\` controlados por el módulo propietario.
|
||||
- Contexto automático de source, operación, actor y scope.
|
||||
- Atomicidad fuerte con MariaDB/Sequelize para mutaciones funcionales.
|
||||
- Lectura eficiente para una futura UI administrativa con filtros, detalle y paginación.
|
||||
- Diseño compatible con un administrador limitado a uno o varios scopes y con un administrador global.
|
||||
|
||||
## 2.2 Fuera de alcance
|
||||
|
||||
- Event Sourcing o reconstrucción del estado de negocio a partir del audit log.
|
||||
- BusinessVersion de presupuestos, proformas u otros documentos funcionales.
|
||||
- DocumentVersion para binarios o gestor documental.
|
||||
- Auditar automáticamente todas las lecturas o validaciones fallidas.
|
||||
- Sistema SIEM, alertas o severidades de seguridad.
|
||||
- Retención legal, archivado, particionado o purge en esta primera versión.
|
||||
- Búsqueda full-text sobre \`before_value\` / \`after_value\`.
|
||||
- Histórico granular de arrays o líneas individuales en la primera versión.
|
||||
|
||||
# 3\. Terminología e invariantes
|
||||
|
||||
| **Concepto** | **Semántica** |
|
||||
| ------------- | ---------------------------------------------------------------------------- |
|
||||
| AuditEntry | Unidad histórica append-only generada por Audit. |
|
||||
| Event | Hecho auditable sin diff de estado. |
|
||||
| Mutation | Hecho auditable con uno o más cambios derivados de before/after. |
|
||||
| Source | Sistema que produjo el registro, p.ej. \`application:uecko-erp\`. |
|
||||
| Actor | Quién o qué ejecutó la acción. |
|
||||
| Scope | Ámbito donde ocurrió; puede ser null. |
|
||||
| Target | Recurso o categoría sobre la que ocurrió la acción. |
|
||||
| Operation | Ejecución lógica local que puede producir varios AuditEntry. |
|
||||
| CorrelationId | Identificador que relaciona varias operaciones, incluso entre sistemas. |
|
||||
| Action | Identificador histórico estable de la operación, p.ej. \`customer.updated\`. |
|
||||
|
||||
**Invariante de autorización
|
||||
**\`AuditEntry.scope\` describe dónde ocurrió el hecho. No expresa lo que un usuario puede consultar. \`scope=null\` nunca significa wildcard, superadmin ni acceso global.
|
||||
|
||||
# 4\. Arquitectura
|
||||
|
||||
apps/server
|
||||
│
|
||||
├── OperationContextStore
|
||||
├── SequelizeTransactionContext
|
||||
├── Audit
|
||||
│ ├── IAuditContextProvider
|
||||
│ └── IAuditStore
|
||||
│ └── SequelizeAuditStore
|
||||
│
|
||||
└── initModules(ModuleParams)
|
||||
├── database
|
||||
├── logger
|
||||
├── audit
|
||||
└── transactionContext
|
||||
|
||||
Los módulos no declaran \`dependencies: \["audit"\]\` ni resuelven servicios \`audit:\*\`. \`audit\` es infraestructura transversal suministrada por el runtime.
|
||||
|
||||
## 4.1 Flujo de una mutación
|
||||
|
||||
HTTP / job / integration
|
||||
│
|
||||
▼
|
||||
OperationContext
|
||||
│
|
||||
▼
|
||||
Use Case / Application Service
|
||||
│
|
||||
▼
|
||||
TransactionManager.complete()
|
||||
│
|
||||
├── TransactionContext = T1
|
||||
├── repository.save(..., T1)
|
||||
└── audit.recordMutation(...)
|
||||
│
|
||||
▼
|
||||
AuditEntry
|
||||
│
|
||||
▼
|
||||
SequelizeAuditStore
|
||||
│
|
||||
└── TransactionContext.current() = T1
|
||||
│
|
||||
▼
|
||||
COMMIT o ROLLBACK
|
||||
|
||||
# 5\. Paquete portable \`@repo/rdx-audit\`
|
||||
|
||||
## 5.1 API pública
|
||||
|
||||
export interface IAudit {
|
||||
record(input: RecordAuditInput): Promise<Result<void, AuditError>>;
|
||||
recordEvent(input: RecordAuditEventShortcutInput): Promise<Result<void, AuditError>>;
|
||||
recordMutation(input: RecordAuditMutationShortcutInput): Promise<Result<void, AuditError>>;
|
||||
}
|
||||
<br/>export interface IAuditStore {
|
||||
append(entry: AuditEntry): Promise<Result<void, AuditStoreError>>;
|
||||
}
|
||||
|
||||
\`record()\` es la única operación canónica. Los shortcuts añaden \`kind\` y delegan íntegramente en \`record()\`.
|
||||
|
||||
## 5.2 Inputs
|
||||
|
||||
export interface RecordAuditEventInput {
|
||||
readonly kind: "event";
|
||||
readonly action: string;
|
||||
readonly target: AuditEventTarget;
|
||||
}
|
||||
<br/>export interface RecordAuditMutationInput {
|
||||
readonly kind: "mutation";
|
||||
readonly action: string;
|
||||
readonly target: AuditMutationTarget;
|
||||
readonly before: AuditState;
|
||||
readonly after: AuditState;
|
||||
readonly atomicPaths?: readonly string\[\];
|
||||
}
|
||||
<br/>export type RecordAuditInput =
|
||||
| RecordAuditEventInput
|
||||
| RecordAuditMutationInput;
|
||||
|
||||
## 5.3 Contratos estructurales
|
||||
|
||||
export interface AuditSource {
|
||||
readonly type: string;
|
||||
readonly id: string;
|
||||
}
|
||||
<br/>export interface AuditOperation {
|
||||
readonly id: string;
|
||||
readonly correlationId: string;
|
||||
readonly origin: string;
|
||||
}
|
||||
<br/>export interface AuditActor {
|
||||
readonly type: string;
|
||||
readonly id: string | null;
|
||||
readonly label: string | null;
|
||||
}
|
||||
<br/>export interface AuditScope {
|
||||
readonly type: string;
|
||||
readonly id: string;
|
||||
readonly label: string | null;
|
||||
}
|
||||
<br/>export interface AuditEventTarget {
|
||||
readonly type: string;
|
||||
readonly id: string | null;
|
||||
readonly label: string | null;
|
||||
}
|
||||
<br/>export interface AuditMutationTarget {
|
||||
readonly type: string;
|
||||
readonly id: string;
|
||||
readonly label: string | null;
|
||||
}
|
||||
|
||||
**Decisión KISS
|
||||
**No se introduce una interfaz base común para source/actor/scope/target. La duplicación es mínima y las invariantes y evolución de cada concepto son distintas.
|
||||
|
||||
# 6\. \`AuditState\` y semántica de diff
|
||||
|
||||
export type AuditScalar = string | number | boolean | null;
|
||||
export type AuditObject = Readonly<{ \[key: string\]: AuditValue }>;
|
||||
export type AuditArray = readonly AuditValue\[\];
|
||||
export type AuditValue = AuditScalar | AuditObject | AuditArray;
|
||||
export type AuditState = Readonly<Record<string, AuditValue>>;
|
||||
|
||||
- No admite \`undefined\`, \`Date\`, \`Buffer\`, \`bigint\`, funciones, instancias de clase ni ciclos.
|
||||
- Los números deben ser finitos.
|
||||
- Las keys auditables no contienen \`.\`; el punto queda reservado para paths.
|
||||
- Valores exactos como Money/Percentage/Quantity se proyectan a objetos estables con strings cuando corresponda.
|
||||
- El módulo construye una proyección histórica controlada; Audit nunca introspecciona aggregates, DTOs o modelos Sequelize.
|
||||
- Secrets no se materializan. Datos personales solo se incluyen cuando exista justificación histórica.
|
||||
|
||||
## 6.1 Reglas de diff
|
||||
|
||||
| **Caso** | **Regla** |
|
||||
| --------------------------- | --------------------------------------------------- |
|
||||
| Objetos | Comparación recursiva; el orden de keys no importa. |
|
||||
| Arrays | Atómicos y order-sensitive en v1. |
|
||||
| atomicPaths | Permite tratar un objeto completo como una unidad. |
|
||||
| set | Campo inexistente antes y presente después. |
|
||||
| unset | Campo presente antes y ausente después. |
|
||||
| change | Campo presente en ambos con valores diferentes. |
|
||||
| Cambio de tipo incompatible | Se registra en el path padre. |
|
||||
| Orden de changes | Lexicográfico por \`path\`. |
|
||||
| Mutation sin cambios | \`Result.ok()\` y no se crea \`AuditEntry\`. |
|
||||
|
||||
## 6.2 \`AuditChange\`
|
||||
|
||||
export interface AuditChange {
|
||||
readonly path: string;
|
||||
readonly kind: "set" | "unset" | "change";
|
||||
readonly before: AuditValue | null;
|
||||
readonly after: AuditValue | null;
|
||||
}
|
||||
|
||||
# 7\. Modelo portable \`AuditEntry\`
|
||||
|
||||
interface AuditEntryBase {
|
||||
readonly id: string;
|
||||
readonly occurredAt: string;
|
||||
readonly source: AuditSource;
|
||||
readonly operation: AuditOperation;
|
||||
readonly actor: AuditActor;
|
||||
readonly scope: AuditScope | null;
|
||||
readonly action: string;
|
||||
}
|
||||
<br/>export interface AuditEventEntry extends AuditEntryBase {
|
||||
readonly kind: "event";
|
||||
readonly target: AuditEventTarget;
|
||||
readonly changes: readonly \[\];
|
||||
}
|
||||
<br/>export interface AuditMutationEntry extends AuditEntryBase {
|
||||
readonly kind: "mutation";
|
||||
readonly target: AuditMutationTarget;
|
||||
readonly changes: readonly AuditChange\[\];
|
||||
}
|
||||
<br/>export type AuditEntry = AuditEventEntry | AuditMutationEntry;
|
||||
|
||||
- \`event\` exige \`changes=\[\]\`.
|
||||
- \`mutation\` exige \`changes.length > 0\` en runtime y target identificado.
|
||||
- El entry completo es serializable y portable entre MariaDB, JSONL, servicio HTTP, Kafka u otro store.
|
||||
- El store nunca recibe \`before\`, \`after\`, \`atomicPaths\` ni \`OperationContext\`; recibe el \`AuditEntry\` definitivo.
|
||||
|
||||
# 8\. \`action\`, source, actor, scope y target
|
||||
|
||||
## 8.1 Action
|
||||
|
||||
\`action\` es un string validado y un contrato histórico estable propiedad del módulo funcional. No existe enum global ni registry.
|
||||
|
||||
- Convención: segmentos lowercase separados por punto, con mínimo dos segmentos.
|
||||
- El primer segmento suele identificar el área o recurso propietario: \`customer.updated\`, \`proforma.issued\`, \`document.version.created\`.
|
||||
- No se incluyen IDs ni datos variables en la acción.
|
||||
- No se guarda \`actionLabel\`; la UI resuelve i18n y usa el identificador como fallback.
|
||||
- No se persisten \`category\`, \`namespace\` ni \`severity\` en v1.
|
||||
- Una acción publicada en producción no se renombra por razones estéticas; si cambia la semántica, se crea otra acción.
|
||||
|
||||
## 8.2 Source
|
||||
|
||||
Identifica el productor técnico del registro y se configura una sola vez al construir \`Audit\`. Ejemplo: \`{ type: "application", id: "uecko-erp" }\`.
|
||||
|
||||
## 8.3 Actor
|
||||
|
||||
Genérico: \`{ type, id|null, label|null }\`. El ERP puede usar \`user\`, \`system\`, \`integration\`, \`job\`, \`anonymous\`, sin que \`rdx-audit\` los enumere.
|
||||
|
||||
## 8.4 Scope
|
||||
|
||||
Genérico y opcional: company, organization, workspace, project u otro ámbito. Si existe, \`id\` es obligatorio. \`scope=null\` significa que el evento no tiene un ámbito concreto.
|
||||
|
||||
## 8.5 Target
|
||||
|
||||
Identifica la categoría/recurso afectado. Un Event puede tener \`id=null\`; una Mutation requiere un target identificado.
|
||||
|
||||
# 9\. Identificadores y contexto de operación
|
||||
|
||||
| **Identificador** | **Responsable** | **Semántica** |
|
||||
| ----------------- | ------------------------ | ------------------------------------------------------- |
|
||||
| AuditEntry.id | Audit | Identidad global e inmutable del registro. |
|
||||
| Operation.id | OperationContext host | Identidad de una ejecución local. |
|
||||
| correlationId | Contexto host / upstream | Relaciona operaciones; puede propagarse entre sistemas. |
|
||||
|
||||
- Los tres son strings opacos en el contrato portable.
|
||||
- \`AuditEntry.id\` se genera antes del store y no usa AUTO_INCREMENT como identidad lógica.
|
||||
- \`operation.id\` lo genera siempre el backend; no se confía en un ID de request aportado por cliente.
|
||||
- \`correlationId\` puede recibirse de upstream si se valida; si no existe, se genera.
|
||||
- \`correlationId\` es obligatorio; no se usa null.
|
||||
- \`occurredAt\` lo genera Audit en ISO-8601 UTC y precisión de milisegundos.
|
||||
- No se introduce \`causationId\` ni \`idempotencyKey\` en v1.
|
||||
|
||||
# 10\. OperationContext e \`IAuditContextProvider\`
|
||||
|
||||
export interface AuditContext {
|
||||
readonly operation: AuditOperation;
|
||||
readonly actor: AuditActor;
|
||||
readonly scope: AuditScope | null;
|
||||
}
|
||||
<br/>export interface IAuditContextProvider {
|
||||
get(): AuditContext | null;
|
||||
}
|
||||
|
||||
\`rdx-audit\` no conoce \`AsyncLocalStorage\` ni el \`OperationContextStore\` concreto. El host adapta su contexto al puerto mínimo anterior.
|
||||
|
||||
- HTTP abre el contexto con actor anónimo y scope null; autenticación puede enriquecer actor y tenancy puede enriquecer scope.
|
||||
- \`operationId\`, \`correlationId\` y \`origin\` son inmutables dentro de una operación.
|
||||
- La transición de scope permitida es null -> A. A -> B está prohibida dentro de la misma operación.
|
||||
- Jobs, integraciones y migraciones deben abrir su propio contexto explícito.
|
||||
- Ausencia de contexto al auditar es fail-fast.
|
||||
|
||||
# 11\. \`SequelizeTransactionContext\`
|
||||
|
||||
Es infraestructura host-specific, independiente de \`OperationContext\` y fuera de \`@repo/rdx-audit\`. Se distribuye por DI y se implementa con \`AsyncLocalStorage\`.
|
||||
|
||||
export interface ISequelizeTransactionContext {
|
||||
run<T>(transaction: Transaction, callback: () => T): T;
|
||||
current(): Transaction | null;
|
||||
}
|
||||
|
||||
- Los repositorios y servicios funcionales continúan recibiendo \`Transaction\` explícitamente.
|
||||
- \`SequelizeTransactionManager.complete()\` abre el scope del context alrededor del callback.
|
||||
- Debe conservarse el comportamiento actual: un \`Result.fail\` dentro del callback provoca rollback.
|
||||
- Las transacciones anidadas quedan prohibidas inicialmente; no hay evidencia productiva que requiera savepoints o nesting.
|
||||
- El contexto se inyecta; no se utiliza singleton global.
|
||||
|
||||
**Deuda técnica detectada
|
||||
**El manager actual declara protección frente a anidamiento, pero su \`complete()\` no mantiene \`\_transaction\`; esa incoherencia debe corregirse o cubrirse con la nueva política de nesting, sin mezclar una refactorización mayor con audit.
|
||||
|
||||
# 12\. Stores y consistencia
|
||||
|
||||
## 12.1 Puerto portable
|
||||
|
||||
export interface IAuditStore {
|
||||
append(entry: AuditEntry): Promise<Result<void, AuditStoreError>>;
|
||||
}
|
||||
|
||||
- Solo append. No update, delete ni upsert.
|
||||
- La lectura usa otro adapter; no se mezcla en el write port.
|
||||
- El entry se genera completamente antes del store.
|
||||
- El store debe preservar el orden de \`changes\`.
|
||||
- Un fallo del store no genera a su vez otro AuditEntry.
|
||||
|
||||
## 12.2 Adapters previstos
|
||||
|
||||
| **Adapter** | **Garantía** |
|
||||
| ------------------- | ---------------------------------------------------------------------- |
|
||||
| SequelizeAuditStore | Store productivo principal; puede compartir la transacción funcional. |
|
||||
| FileAuditStore | Útil para desarrollo/best-effort; no es ACID respecto al negocio. |
|
||||
| ExternalAuditStore | Portable, pero no puede garantizar atomicidad con MariaDB por sí solo. |
|
||||
| Outbox + external | Evolución futura si se necesita entrega externa fuerte sin 2PC. |
|
||||
|
||||
**Regla de producción
|
||||
**Una configuración que exija consistencia fuerte no debe aceptar como store primario un FileAuditStore o un external store no transaccional. La validación de esa combinación pertenece al bootstrap del host, no a \`rdx-audit\`.
|
||||
|
||||
# 13\. \`SequelizeAuditStore\` y esquema MariaDB
|
||||
|
||||
La representación relacional utiliza dos tablas. No existe \`audit_change_sets\`.
|
||||
|
||||
audit_events
|
||||
1 ─────── 0..N audit_changes
|
||||
|
||||
## 13.1 \`audit_events\`
|
||||
|
||||
| **Columna** | **Tipo propuesto** | **Nulabilidad** |
|
||||
| -------------- | ------------------ | --------------- |
|
||||
| id | VARCHAR(64) | NOT NULL, PK |
|
||||
| kind | VARCHAR(32) | NOT NULL |
|
||||
| occurred_at | DATETIME(3) | NOT NULL |
|
||||
| source_type | VARCHAR(64) | NOT NULL |
|
||||
| source_id | VARCHAR(128) | NOT NULL |
|
||||
| operation_id | VARCHAR(128) | NOT NULL |
|
||||
| correlation_id | VARCHAR(128) | NOT NULL |
|
||||
| origin | VARCHAR(64) | NOT NULL |
|
||||
| actor_type | VARCHAR(64) | NOT NULL |
|
||||
| actor_id | VARCHAR(128) | NULL |
|
||||
| actor_label | VARCHAR(255) | NULL |
|
||||
| scope_type | VARCHAR(64) | NULL |
|
||||
| scope_id | VARCHAR(128) | NULL |
|
||||
| scope_label | VARCHAR(255) | NULL |
|
||||
| action | VARCHAR(191) | NOT NULL |
|
||||
| target_type | VARCHAR(64) | NOT NULL |
|
||||
| target_id | VARCHAR(128) | NULL |
|
||||
| target_label | VARCHAR(255) | NULL |
|
||||
|
||||
## 13.2 \`audit_changes\`
|
||||
|
||||
| **Columna** | **Tipo propuesto** | **Notas** |
|
||||
| -------------- | ------------------ | ------------------------ |
|
||||
| audit_event_id | VARCHAR(64) | PK/FK -> audit_events.id |
|
||||
| position | INT UNSIGNED | PK; preserva orden |
|
||||
| path | VARCHAR(255) | NOT NULL |
|
||||
| kind | VARCHAR(32) | set/unset/change |
|
||||
| before_value | JSON | NULL |
|
||||
| after_value | JSON | NULL |
|
||||
|
||||
- MariaDB \`JSON\` es adecuado para presentar valores históricos; no se diseña como mecanismo principal de búsqueda.
|
||||
- No existen FKs a Company, User, Customer, Proforma u otros recursos funcionales.
|
||||
- \`audit_changes -> audit_events\` sí tiene integridad referencial interna; \`ON DELETE CASCADE\` puede reservarse para retención administrativa futura.
|
||||
- No se usan timestamps Sequelize automáticos \`createdAt/updatedAt\`.
|
||||
- No se añade \`stored_at\`, soft delete, particionado, metadata genérica ni índices JSON en v1.
|
||||
|
||||
## 13.3 Constraints
|
||||
|
||||
- \`audit_events.kind IN (event, mutation)\`.
|
||||
- \`audit_changes.kind IN (set, unset, change)\`.
|
||||
- Scope: o todos sus campos son null, o al menos \`scope_type\` y \`scope_id\` están presentes.
|
||||
- \`kind=mutation\` implica \`target_id IS NOT NULL\`.
|
||||
- Las invariantes se validan también antes de persistir; las CHECK constraints son defensa adicional.
|
||||
|
||||
## 13.4 Índices iniciales
|
||||
|
||||
PRIMARY KEY (id)
|
||||
INDEX (occurred_at, id)
|
||||
INDEX (scope_type, scope_id, occurred_at, id)
|
||||
INDEX (target_type, target_id, occurred_at, id)
|
||||
INDEX (actor_type, actor_id, occurred_at, id)
|
||||
INDEX (operation_id)
|
||||
INDEX (correlation_id)
|
||||
|
||||
No se indexan inicialmente \`kind\`, \`path\`, JSON values, source ni action. Se añadirán solo si las consultas reales justifican el coste de escritura.
|
||||
|
||||
# 14\. Lectura administrativa
|
||||
|
||||
\`IAuditReader\` no forma parte del core portable porque el modelo actual de Criteria se exporta desde \`@repo/rdx-criteria/server\` y arrastra Sequelize. La lectura se implementa en infraestructura server-side.
|
||||
|
||||
interface IAuditReader {
|
||||
search(
|
||||
criteria: Criteria,
|
||||
access: AuditReadAccess,
|
||||
): Promise<Result<AuditSearchResult, Error>>;
|
||||
<br/>findById(
|
||||
id: string,
|
||||
access: AuditReadAccess,
|
||||
): Promise<Result<AuditEntry | null, Error>>;
|
||||
}
|
||||
|
||||
## 14.1 \`AuditReadAccess\`
|
||||
|
||||
type AuditReadAccess =
|
||||
| Readonly<{ mode: "unrestricted" }>
|
||||
| Readonly<{
|
||||
mode: "scoped";
|
||||
scopes: readonly AuditScopeIdentity\[\];
|
||||
includeUnscoped: boolean;
|
||||
}>;
|
||||
<br/>interface AuditScopeIdentity {
|
||||
readonly type: string;
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
- Administrador limitado: \`mode=scoped\`, uno o varios scopes autorizados.
|
||||
- Administrador global: \`mode=unrestricted\` y no se añade predicado de scope.
|
||||
- \`includeUnscoped\` es explícito; no se infiere.
|
||||
- El access model no utiliza \`OperationContext.scope\` como autorización automática.
|
||||
|
||||
## 14.2 Criteria
|
||||
|
||||
- Se reutiliza \`@repo/rdx-criteria/server\` solo dentro del reader Sequelize.
|
||||
- El reader mantiene whitelist propia de campos y operadores porque el converter actual puede utilizar un field desconocido como columna Sequelize.
|
||||
- Los mappings lógico -> columna son explícitos para todos los fields auditables.
|
||||
- \`strictMode\` se activa para sorting, pero no sustituye la whitelist de filtros.
|
||||
- Paginación inicial: \`pageNumber/pageSize\`, zero-based, coherente con el backend actual.
|
||||
- Orden por defecto: \`occurredAt DESC\`; el reader añade \`id DESC\` como desempate técnico.
|
||||
- El máximo de \`pageSize\` se valida en el boundary porque el MAX actual no está aplicado de forma global.
|
||||
|
||||
## 14.3 Filtros iniciales
|
||||
|
||||
| **Campo** | **Operadores previstos** |
|
||||
| -------------------------------- | ---------------------------------- |
|
||||
| id | EQUALS |
|
||||
| kind | EQUALS |
|
||||
| occurredAt | EQUALS, >, >=, <, <= |
|
||||
| sourceType/sourceId | EQUALS |
|
||||
| operationId/correlationId/origin | EQUALS |
|
||||
| actorType/actorId | EQUALS; null checks cuando aplique |
|
||||
| scopeType/scopeId | EQUALS, IS_NULL, IS_NOT_NULL |
|
||||
| action | EQUALS; búsqueda libre separada |
|
||||
| targetType/targetId | EQUALS; null checks cuando aplique |
|
||||
|
||||
## 14.4 Quick search
|
||||
|
||||
Primera versión: quick search específico con LIKE sobre \`action\`, \`actor_label\`, \`scope_label\` y \`target_label\`. No se crean FULLTEXT indexes ni se busca dentro de JSON en v1.
|
||||
|
||||
## 14.5 Limitaciones actuales de Criteria
|
||||
|
||||
- No hay \`IN\`, \`STARTS_WITH\` ni múltiples órdenes expresables en Criteria.
|
||||
- La autorización multi-scope se construye directamente como \`WhereOptions\`, no mediante Criteria.
|
||||
- El prefijo \`customer.\*\` no es requisito de v1; si aparece necesidad real puede añadirse \`STARTS_WITH\` como extensión genérica.
|
||||
- Existe una incoherencia \`NOT_EQUALS\` / \`NOT_EQUAL\` en \`rdx-criteria\`; se registra como deuda técnica independiente.
|
||||
|
||||
# 15\. Futura API/UI administrativa
|
||||
|
||||
La pantalla aún no está definida, pero el backend queda preparado para list/detail sin comprometer la autorización futura.
|
||||
|
||||
GET /admin/audit?...criteria...
|
||||
GET /admin/audit/:audit_event_id
|
||||
|
||||
Las rutas son orientativas; la ubicación definitiva se decidirá al diseñar el módulo de administración.
|
||||
|
||||
- El listado consulta solo \`audit_events\` y devuelve un summary sin \`changes\`.
|
||||
- El detalle consulta \`audit_events\` con \`id + access\` en la misma query y después carga \`audit_changes\` ordenados por position.
|
||||
- No se realiza un \`findById\` unrestricted seguido de verificación posterior.
|
||||
- Los HTTP DTOs siguen separados del modelo portable \`AuditEntry\` y se validan con los patrones DTO/Zod vigentes del ERP.
|
||||
- La UI resuelve labels de acciones mediante i18n y usa el action estable como fallback.
|
||||
|
||||
# 16\. Integración con \`ModuleParams\` y módulos
|
||||
|
||||
interface ModuleParams {
|
||||
// contratos actuales...
|
||||
readonly audit: IAudit;
|
||||
readonly transactionContext: ISequelizeTransactionContext;
|
||||
}
|
||||
|
||||
No se acomete ahora un refactor general del tipado débil de \`ModuleParams\`; audit solo añade las dependencias necesarias.
|
||||
|
||||
- \`transactionContext\` se pasa a los 14 callers actuales de \`buildTransactionManager\` mediante DI explícita.
|
||||
- \`audit\` solo se inyecta en use cases/application services que auditan operaciones.
|
||||
- No se inyecta en repositories, mappers, domain entities ni controllers.
|
||||
- Cross-module services que posean una acción auditada reciben \`IAudit\` en su propio composition root; el caller no pasa audit como argumento.
|
||||
- Cada action tiene un único owner funcional para evitar duplicados.
|
||||
|
||||
# 17\. Patrones normativos por operación
|
||||
|
||||
| **Operación** | **before** | **after** | **Action** |
|
||||
| ----------------- | --------------- | ----------------- | --------------------------------------------- |
|
||||
| Create | {} | Estado persistido | \`\*.created\` |
|
||||
| Update ordinario | Estado anterior | Estado nuevo | \`\*.updated\` |
|
||||
| Update semántico | Estado anterior | Estado nuevo | Acción propia, p.ej. \`customer.deactivated\` |
|
||||
| Delete físico | Estado anterior | {} | \`\*.deleted\` |
|
||||
| Evento no mutante | — | — | \`recordEvent()\` |
|
||||
|
||||
## 17.1 Secuencia de mutation
|
||||
|
||||
load aggregate
|
||||
\-> build BEFORE AuditState
|
||||
\-> apply domain mutation
|
||||
\-> persist
|
||||
\-> build AFTER AuditState
|
||||
\-> audit.recordMutation()
|
||||
\-> commit
|
||||
|
||||
**Orden obligatorio
|
||||
**Audit se registra después de que la persistencia funcional haya sido aceptada, pero antes de abandonar el callback transaccional. Si el audit falla, la transacción completa debe hacer rollback.
|
||||
|
||||
## 17.2 Proyección por módulo
|
||||
|
||||
- No se usa \`aggregate.toPrimitives()\` automáticamente.
|
||||
- Cada módulo define una proyección histórica explícita y determinista.
|
||||
- Referencias legibles pueden representarse como \`{ id, label }\` y marcarse en \`atomicPaths\`.
|
||||
- Arrays se normalizan en el módulo cuando su orden no sea semántico.
|
||||
- Create usa \`before={}\` para obtener un diff de campos establecidos.
|
||||
- Delete captura target y before antes de borrar físicamente.
|
||||
|
||||
# 18\. Errores y atomicidad
|
||||
|
||||
- \`recordMutation()\` y \`recordEvent()\` devuelven \`Result\`; no son fire-and-forget.
|
||||
- El use case propaga \`Result.fail\` sin exponer detalles SQL.
|
||||
- En una mutation productiva: audit failure -> use case failure -> \`TransactionManager.complete()\` detecta Result.fail -> rollback.
|
||||
- \`SequelizeAuditStore\` requiere transacción activa para mutations bajo política strong/required; no abre silenciosamente otra transacción.
|
||||
- Un Event puede reutilizar una transacción activa o realizar una escritura autónoma si la política host lo permite.
|
||||
- \`rdx-audit\` no contiene flags \`required/bestEffort\`; la combinación store/consistency se valida al construir el runtime.
|
||||
|
||||
# 19\. Seguridad, privacidad y observabilidad
|
||||
|
||||
- No se almacenan passwords, tokens, secretos, claves privadas ni credenciales.
|
||||
- PII se limita a valores que tengan utilidad histórica justificada; labels son snapshots auxiliares, no descripciones completas.
|
||||
- No existen FKs a recursos funcionales para preservar el registro histórico aunque el recurso cambie o desaparezca.
|
||||
- La auditoría no sustituye al logging. Reads, validation failures y errores técnicos se observan mediante logs/tracing salvo eventos explícitos de negocio/seguridad.
|
||||
- No se persisten roles/permisos del actor en cada entry. La futura autorización de lectura usa el estado de autorización del host.
|
||||
- El usuario DB de runtime debería aplicar menor privilegio cuando la infraestructura general lo permita; no se introducen triggers anti-update en v1.
|
||||
|
||||
# 20\. Estrategia de pruebas
|
||||
|
||||
## 20.1 \`rdx-audit\`
|
||||
|
||||
- Validación de AuditState y rechazo de valores no portables.
|
||||
- Diff recursivo de objetos, arrays atómicos, atomicPaths y orden lexicográfico.
|
||||
- Event siempre con changes vacío.
|
||||
- Mutation sin cambios devuelve éxito sin append.
|
||||
- Mutation con target no identificado se rechaza.
|
||||
- Context inexistente produce error fail-fast.
|
||||
- IDs y clock deterministas en tests mediante dependencias sustituibles si se introducen.
|
||||
|
||||
## 20.2 TransactionContext
|
||||
|
||||
- \`current()\` es null fuera de \`complete()\`.
|
||||
- Dentro de \`complete()\` devuelve la misma Transaction que recibe el callback.
|
||||
- Restauración del scope al finalizar y también ante error.
|
||||
- \`Result.fail\` sigue provocando rollback.
|
||||
- Nesting prohibido se detecta explícitamente.
|
||||
|
||||
## 20.3 SequelizeAuditStore
|
||||
|
||||
- Event inserta solo \`audit_events\`.
|
||||
- Mutation inserta event + changes de forma atómica.
|
||||
- Fallos en changes provocan rollback completo.
|
||||
- Mutation sin transaction activa bajo política required falla.
|
||||
- Mapping JSON y orden \`position\` son correctos.
|
||||
|
||||
## 20.4 Módulos consumidores
|
||||
|
||||
- Verificar action, target, before y after en las operaciones instrumentadas.
|
||||
- Los unit tests mockean/fakean \`IAudit\`; no necesitan conocer store, OperationContext ni TransactionContext.
|
||||
- La atomicidad completa se prueba en infraestructura y en uno o dos flujos representativos, no repetida en cada use case.
|
||||
|
||||
# 21\. Plan de implementación incremental
|
||||
|
||||
1. Crear \`@repo/rdx-audit\` con tipos portables, \`Audit\`, validación y diff.
|
||||
2. Introducir \`OperationContext\`/adapter \`IAuditContextProvider\` según el contrato acordado.
|
||||
3. Crear \`ISequelizeTransactionContext\` e implementación con AsyncLocalStorage.
|
||||
4. Adaptar \`SequelizeTransactionManager.complete()\` conservando rollback de \`Result.fail\` y política de no nesting.
|
||||
5. Construir una única instancia de transaction context en \`apps/server\` y pasarla por \`ModuleParams\`/runtime de tests.
|
||||
6. Actualizar los 14 callers de \`buildTransactionManager\`.
|
||||
7. Implementar modelos, migración y \`SequelizeAuditStore\`.
|
||||
8. Configurar \`Audit\` una vez en \`apps/server\` y pasarlo a módulos.
|
||||
9. Instrumentar un módulo piloto con create/update/status change y validar ergonomía.
|
||||
10. Extender progresivamente la cobertura a payment terms, customers, proformas, issued invoices y resto.
|
||||
11. Implementar \`SequelizeAuditReader\` y autorización cuando se diseñe la pantalla administrativa.
|
||||
|
||||
# 22\. Cobertura y ownership por módulo
|
||||
|
||||
La adopción es explícita. Un módulo solo se considera cubierto cuando se documentan sus operaciones mutables auditadas.
|
||||
|
||||
Customers audit coverage
|
||||
\[x\] create
|
||||
\[x\] update
|
||||
\[x\] deactivate
|
||||
\[x\] reactivate
|
||||
\[ \] delete - no existe
|
||||
|
||||
No se crea un registry global de acciones. El owner funcional puede usar constantes locales para evitar typos cuando haya varias acciones.
|
||||
|
||||
# 23\. Alternativas descartadas
|
||||
|
||||
| **Alternativa** | **Motivo de descarte** |
|
||||
| ------------------------------------ | ------------------------------------------------------------------------------------------------ |
|
||||
| \`modules/audit\` + service registry | Audit es infraestructura transversal; añadiría wiring funcional artificial. |
|
||||
| \`record(input, transaction)\` | Filtra semántica Sequelize al contrato portable y empeora stores alternativos. |
|
||||
| Transaction context singleton | Reduce 14 cambios a costa de estado global oculto y peor testabilidad. |
|
||||
| Audit Sequelize hooks | No conocen adecuadamente la semántica funcional y pueden duplicar/registrar cambios no deseados. |
|
||||
| Audit desde Domain | Acopla dominio a infraestructura transversal. |
|
||||
| Audit desde controllers | No posee la semántica ni atomicidad funcional completa. |
|
||||
| Criteria dentro de \`rdx-audit\` | El Criteria actual server-side depende de Sequelize; duplicar DSL tampoco aporta valor. |
|
||||
| \`scope=null\` = acceso global | Mezcla dato histórico con autorización y hace ambiguas las consultas. |
|
||||
| Arrays granulares v1 | Complejidad innecesaria; arrays atómicos cubren el alcance inicial. |
|
||||
| Event Sourcing | No es requisito y cambiaría radicalmente el modelo de persistencia funcional. |
|
||||
|
||||
# 24\. Evoluciones futuras, no bloqueantes
|
||||
|
||||
- \`IAuditReader\` con cursor/keyset si el volumen hace costoso el offset.
|
||||
- Operadores genéricos \`STARTS_WITH\`, \`IN\` y orden múltiple en \`rdx-criteria\` si aparecen casos reales.
|
||||
- Full-text o índices adicionales según métricas reales de la UI administrativa.
|
||||
- Retención, purge administrativo, exportación y archivado.
|
||||
- Outbox para envío fiable a store externo.
|
||||
- Diff granular de Collections/arrays con keys estables si aporta valor.
|
||||
- \`causationId\` en flujos event-driven futuros.
|
||||
- \`source.version\` o environment si un mismo store mezcla despliegues/sistemas y aparece necesidad operativa.
|
||||
- BusinessVersion y DocumentVersion como capacidades separadas, no absorbidas por Audit.
|
||||
|
||||
# 25\. Checklist de decisiones cerradas
|
||||
|
||||
| **Tema** | **Decisión** |
|
||||
| ------------------- | ------------------------------------------------------- |
|
||||
| Core portable | @repo/rdx-audit |
|
||||
| IAudit | record + recordEvent + recordMutation |
|
||||
| Store | append-only |
|
||||
| Scope | genérico; null permitido |
|
||||
| Autorización | host-specific y separada |
|
||||
| Transacción | ambient solo para infraestructura; explícita en negocio |
|
||||
| Transaction context | DI, no singleton |
|
||||
| Nesting | prohibido inicialmente |
|
||||
| Persistencia SQL | audit_events + audit_changes |
|
||||
| Reader | server-side con Criteria |
|
||||
| Arrays | atómicos v1 |
|
||||
| Create | before={} |
|
||||
| Delete | after={} |
|
||||
| No-op mutation | no AuditEntry |
|
||||
| Lecturas | no audit por defecto |
|
||||
| Adopción | incremental |
|
||||
|
||||
# Anexo A. Ejemplo de integración
|
||||
|
||||
return this.transactionManager.complete(async transaction => {
|
||||
const customerResult = await this.customerRepository.findById(
|
||||
customerId,
|
||||
transaction,
|
||||
);
|
||||
<br/>if (customerResult.isFailure) {
|
||||
return customerResult;
|
||||
}
|
||||
<br/>const customer = customerResult.value;
|
||||
const before = toCustomerAuditState(customer);
|
||||
<br/>const updateResult = customer.update(input);
|
||||
if (updateResult.isFailure) {
|
||||
return updateResult;
|
||||
}
|
||||
<br/>const saveResult = await this.customerRepository.save(
|
||||
customer,
|
||||
transaction,
|
||||
);
|
||||
<br/>if (saveResult.isFailure) {
|
||||
return saveResult;
|
||||
}
|
||||
<br/>const auditResult = await this.audit.recordMutation({
|
||||
action: "customer.updated",
|
||||
target: toCustomerAuditTarget(customer),
|
||||
before,
|
||||
after: toCustomerAuditState(customer),
|
||||
});
|
||||
<br/>if (auditResult.isFailure) {
|
||||
return auditResult;
|
||||
}
|
||||
<br/>return Result.ok(customer);
|
||||
});
|
||||
|
||||
# Anexo B. Ejemplo de \`AuditEntry\`
|
||||
|
||||
{
|
||||
"id": "01J...",
|
||||
"kind": "mutation",
|
||||
"occurredAt": "2026-08-24T17:20:10.123Z",
|
||||
"source": { "type": "application", "id": "uecko-erp" },
|
||||
"operation": {
|
||||
"id": "OP1",
|
||||
"correlationId": "CORR1",
|
||||
"origin": "http"
|
||||
},
|
||||
"actor": {
|
||||
"type": "user",
|
||||
"id": "U1",
|
||||
"label": "<admin@example.es>"
|
||||
},
|
||||
"scope": {
|
||||
"type": "company",
|
||||
"id": "C1",
|
||||
"label": "Empresa A"
|
||||
},
|
||||
"action": "customer.updated",
|
||||
"target": {
|
||||
"type": "customer",
|
||||
"id": "CU1",
|
||||
"label": "ACME"
|
||||
},
|
||||
"changes": \[
|
||||
{
|
||||
"path": "email",
|
||||
"kind": "change",
|
||||
"before": "<old@acme.es>",
|
||||
"after": "<new@acme.es>"
|
||||
}
|
||||
\]
|
||||
}
|
||||
@ -47,10 +47,10 @@ export type PaymentMethodsInternalDeps = {
|
||||
export const buildPaymentMethodsDependencies = (
|
||||
params: ModuleParams
|
||||
): PaymentMethodsInternalDeps => {
|
||||
const { database } = params;
|
||||
const { database, transactionContext } = params;
|
||||
|
||||
// Infrastructure
|
||||
const transactionManager = buildTransactionManager(database as Sequelize);
|
||||
const transactionManager = buildTransactionManager(database as Sequelize, transactionContext);
|
||||
const persistenceMappers = buildPaymentMethodPersistenceMappers();
|
||||
|
||||
const repository = buildPaymentMethodRepository({ database, mappers: persistenceMappers });
|
||||
|
||||
@ -43,9 +43,9 @@ export type PaymentTermsInternalDeps = {
|
||||
};
|
||||
|
||||
export const buildPaymentTermsDependencies = (params: ModuleParams): PaymentTermsInternalDeps => {
|
||||
const { database } = params;
|
||||
const { database, transactionContext } = params;
|
||||
|
||||
const transactionManager = buildTransactionManager(database as Sequelize);
|
||||
const transactionManager = buildTransactionManager(database as Sequelize, transactionContext);
|
||||
const persistenceMappers = buildPaymentTermPersistenceMappers();
|
||||
|
||||
const repository = buildPaymentTermRepository({ database, mappers: persistenceMappers });
|
||||
|
||||
@ -43,9 +43,9 @@ export type TaxDefinitionsInternalDeps = {
|
||||
export const buildTaxDefinitionsDependencies = (
|
||||
params: ModuleParams
|
||||
): TaxDefinitionsInternalDeps => {
|
||||
const { database } = params;
|
||||
const { database, transactionContext } = params;
|
||||
|
||||
const transactionManager = buildTransactionManager(database as Sequelize);
|
||||
const transactionManager = buildTransactionManager(database as Sequelize, transactionContext);
|
||||
const persistenceMappers = buildTaxDefinitionPersistenceMappers();
|
||||
|
||||
const repository = buildTaxDefinitionRepository({ database, mappers: persistenceMappers });
|
||||
|
||||
@ -40,9 +40,9 @@ export type TaxRegimesInternalDeps = {
|
||||
};
|
||||
|
||||
export const buildTaxRegimesDependencies = (params: ModuleParams): TaxRegimesInternalDeps => {
|
||||
const { database } = params;
|
||||
const { database, transactionContext } = params;
|
||||
|
||||
const transactionManager = buildTransactionManager(database as Sequelize);
|
||||
const transactionManager = buildTransactionManager(database as Sequelize, transactionContext);
|
||||
const persistenceMappers = buildTaxRegimePersistenceMappers();
|
||||
|
||||
const repository = buildTaxRegimeRepository({ database, mappers: persistenceMappers });
|
||||
|
||||
@ -40,8 +40,8 @@ export type CompaniesInternalDeps = {
|
||||
};
|
||||
|
||||
export const buildCompaniesDependencies = (params: ModuleParams): CompaniesInternalDeps => {
|
||||
const { database, getService } = params;
|
||||
const transactionManager = buildTransactionManager(database as Sequelize);
|
||||
const { database, getService, transactionContext } = params;
|
||||
const transactionManager = buildTransactionManager(database as Sequelize, transactionContext);
|
||||
const responseMapper = new CompanyPublicModelMapper();
|
||||
const identityServices = getService<IIdentityPublicServices>("identity:general");
|
||||
const catalogsDeps = resolveCompanyCatalogsDeps(params);
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"test": "node --import tsx --test",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"check": "biome check .",
|
||||
"lint": "biome lint .",
|
||||
@ -27,6 +28,7 @@
|
||||
"@hookform/devtools": "^4.4.0",
|
||||
"@types/dinero.js": "1.9.1",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"react": "^19.2.5",
|
||||
@ -40,6 +42,7 @@
|
||||
"@repo/rdx-criteria": "workspace:*",
|
||||
"@repo/rdx-ddd": "workspace:*",
|
||||
"@repo/rdx-logger": "workspace:*",
|
||||
"@repo/rdx-audit": "workspace:*",
|
||||
"@repo/rdx-ui": "workspace:*",
|
||||
"@repo/rdx-utils": "workspace:*",
|
||||
"@repo/shadcn-ui": "workspace:*",
|
||||
@ -56,4 +59,4 @@
|
||||
"sequelize": "^6.37.8",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3
modules/core/src/api/infrastructure/context/index.ts
Normal file
3
modules/core/src/api/infrastructure/context/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from "./operation-audit-context-provider";
|
||||
export * from "./operation-context.types";
|
||||
export * from "./operation-context-store";
|
||||
@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { OperationAuditContextProvider } from "./operation-audit-context-provider";
|
||||
import { AsyncLocalStorageOperationContextStore } from "./operation-context-store";
|
||||
|
||||
test("OperationAuditContextProvider devuelve null fuera de una operación", () => {
|
||||
const provider = new OperationAuditContextProvider(new AsyncLocalStorageOperationContextStore());
|
||||
|
||||
assert.equal(provider.get(), null);
|
||||
});
|
||||
|
||||
test("OperationAuditContextProvider adapta el contexto técnico al puerto de auditoría", () => {
|
||||
const store = new AsyncLocalStorageOperationContextStore();
|
||||
const provider = new OperationAuditContextProvider(store);
|
||||
|
||||
store.run(
|
||||
{
|
||||
operationId: "operation-1",
|
||||
correlationId: "correlation-1",
|
||||
origin: "http",
|
||||
actor: { type: "user", id: "user-1", label: "Ada" },
|
||||
scope: { type: "company", id: "company-1", label: "ACME" },
|
||||
},
|
||||
() => {
|
||||
assert.deepEqual(provider.get(), {
|
||||
operation: {
|
||||
id: "operation-1",
|
||||
correlationId: "correlation-1",
|
||||
origin: "http",
|
||||
},
|
||||
actor: { type: "user", id: "user-1", label: "Ada" },
|
||||
scope: { type: "company", id: "company-1", label: "ACME" },
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("OperationAuditContextProvider conserva scope null", () => {
|
||||
const store = new AsyncLocalStorageOperationContextStore();
|
||||
const provider = new OperationAuditContextProvider(store);
|
||||
|
||||
store.run(
|
||||
{
|
||||
operationId: "operation-1",
|
||||
correlationId: "correlation-1",
|
||||
origin: "job",
|
||||
actor: { type: "system", id: "scheduler", label: null },
|
||||
scope: null,
|
||||
},
|
||||
() => {
|
||||
assert.equal(provider.get()?.scope, null);
|
||||
}
|
||||
);
|
||||
});
|
||||
@ -0,0 +1,23 @@
|
||||
import type { AuditContext, IAuditContextProvider } from "@repo/rdx-audit";
|
||||
|
||||
import type { IOperationContextStore } from "./operation-context.types";
|
||||
|
||||
/** Adapta el contexto técnico de operación al puerto portable de auditoría. */
|
||||
export class OperationAuditContextProvider implements IAuditContextProvider {
|
||||
constructor(private readonly contextStore: IOperationContextStore) {}
|
||||
|
||||
get(): AuditContext | null {
|
||||
const context = this.contextStore.get();
|
||||
if (!context) return null;
|
||||
|
||||
return {
|
||||
operation: {
|
||||
id: context.operationId,
|
||||
correlationId: context.correlationId,
|
||||
origin: context.origin,
|
||||
},
|
||||
actor: { ...context.actor },
|
||||
scope: context.scope ? { ...context.scope } : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,94 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { OperationContext } from "./operation-context.types";
|
||||
import {
|
||||
AsyncLocalStorageOperationContextStore,
|
||||
OperationContextEnrichmentError,
|
||||
OperationContextUnavailableError,
|
||||
} from "./operation-context-store";
|
||||
|
||||
const anonymousContext: OperationContext = {
|
||||
operationId: "operation-1",
|
||||
correlationId: "correlation-1",
|
||||
origin: "http",
|
||||
actor: {
|
||||
type: "anonymous",
|
||||
id: null,
|
||||
label: null,
|
||||
},
|
||||
scope: null,
|
||||
};
|
||||
|
||||
test("OperationContext conserva el contexto tras await y restaura scopes anidados", async () => {
|
||||
const store = new AsyncLocalStorageOperationContextStore();
|
||||
const innerContext: OperationContext = {
|
||||
...anonymousContext,
|
||||
operationId: "operation-2",
|
||||
correlationId: "correlation-2",
|
||||
};
|
||||
|
||||
await store.run(anonymousContext, async () => {
|
||||
assert.equal(store.get()?.operationId, "operation-1");
|
||||
await Promise.resolve();
|
||||
assert.equal(store.get()?.operationId, "operation-1");
|
||||
|
||||
await store.run(innerContext, async () => {
|
||||
assert.equal(store.get()?.operationId, "operation-2");
|
||||
await Promise.resolve();
|
||||
assert.equal(store.get()?.operationId, "operation-2");
|
||||
});
|
||||
|
||||
assert.equal(store.get()?.operationId, "operation-1");
|
||||
});
|
||||
|
||||
assert.equal(store.get(), null);
|
||||
});
|
||||
|
||||
test("OperationContext restaura el contexto exterior cuando el interior falla", async () => {
|
||||
const store = new AsyncLocalStorageOperationContextStore();
|
||||
|
||||
await store.run(anonymousContext, async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
store.run(
|
||||
{ ...anonymousContext, operationId: "operation-2", correlationId: "correlation-2" },
|
||||
async () => {
|
||||
throw new Error("inner failure");
|
||||
}
|
||||
),
|
||||
/inner failure/
|
||||
);
|
||||
|
||||
assert.equal(store.get()?.operationId, "operation-1");
|
||||
});
|
||||
});
|
||||
|
||||
test("OperationContext permite enriquecer anonymous y scope una sola vez", () => {
|
||||
const store = new AsyncLocalStorageOperationContextStore();
|
||||
|
||||
assert.throws(() => store.setActor({ type: "user", id: "user-1", label: "Ada" }), {
|
||||
name: OperationContextUnavailableError.name,
|
||||
});
|
||||
assert.throws(() => store.setScope({ type: "company", id: "company-1", label: "ACME" }), {
|
||||
name: OperationContextUnavailableError.name,
|
||||
});
|
||||
|
||||
store.run(anonymousContext, () => {
|
||||
store.setActor({ type: "user", id: "user-1", label: "Ada" });
|
||||
store.setActor({ type: "user", id: "user-1", label: "Other label" });
|
||||
store.setScope({ type: "company", id: "company-1", label: "ACME" });
|
||||
store.setScope({ type: "company", id: "company-1", label: "Other label" });
|
||||
|
||||
assert.deepEqual(store.get()?.actor, { type: "user", id: "user-1", label: "Ada" });
|
||||
assert.deepEqual(store.get()?.scope, { type: "company", id: "company-1", label: "ACME" });
|
||||
assert.throws(
|
||||
() => store.setActor({ type: "user", id: "user-2", label: "Grace" }),
|
||||
OperationContextEnrichmentError
|
||||
);
|
||||
assert.throws(
|
||||
() => store.setScope({ type: "company", id: "company-2", label: "Other" }),
|
||||
OperationContextEnrichmentError
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,128 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
import { InfrastructureError } from "../errors";
|
||||
|
||||
import type {
|
||||
IOperationContextStore,
|
||||
OperationActor,
|
||||
OperationContext,
|
||||
OperationScope,
|
||||
} from "./operation-context.types";
|
||||
|
||||
type ContextHolder = { context: OperationContext };
|
||||
|
||||
export class OperationContextUnavailableError extends InfrastructureError {
|
||||
constructor() {
|
||||
super("[OperationContext] No active operation context.");
|
||||
this.name = new.target.name;
|
||||
}
|
||||
}
|
||||
|
||||
export class OperationContextEnrichmentError extends InfrastructureError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = new.target.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mantiene metadatos de una operación asíncrona. Solo actor y scope pueden
|
||||
* enriquecerse; los identificadores de operación permanecen inmutables.
|
||||
*/
|
||||
export class AsyncLocalStorageOperationContextStore implements IOperationContextStore {
|
||||
private readonly storage = new AsyncLocalStorage<ContextHolder>();
|
||||
|
||||
run<T>(context: OperationContext, callback: () => T): T {
|
||||
return this.storage.run({ context: cloneContext(context) }, callback);
|
||||
}
|
||||
|
||||
get(): OperationContext | null {
|
||||
return this.storage.getStore()?.context ?? null;
|
||||
}
|
||||
|
||||
setActor(actor: OperationActor): void {
|
||||
const holder = this.getHolder();
|
||||
assertActor(actor);
|
||||
const currentActor = holder.context.actor;
|
||||
|
||||
if (sameActor(currentActor, actor)) return;
|
||||
if (!isAnonymous(currentActor)) {
|
||||
throw new OperationContextEnrichmentError(
|
||||
"[OperationContext] A concrete actor cannot be replaced."
|
||||
);
|
||||
}
|
||||
|
||||
holder.context = { ...holder.context, actor: { ...actor } };
|
||||
}
|
||||
|
||||
setScope(scope: OperationScope): void {
|
||||
const holder = this.getHolder();
|
||||
assertScope(scope);
|
||||
const currentScope = holder.context.scope;
|
||||
|
||||
if (currentScope && sameScope(currentScope, scope)) return;
|
||||
if (currentScope) {
|
||||
throw new OperationContextEnrichmentError(
|
||||
"[OperationContext] A concrete scope cannot be replaced."
|
||||
);
|
||||
}
|
||||
|
||||
holder.context = { ...holder.context, scope: { ...scope } };
|
||||
}
|
||||
|
||||
private getHolder(): ContextHolder {
|
||||
const holder = this.storage.getStore();
|
||||
if (!holder) throw new OperationContextUnavailableError();
|
||||
return holder;
|
||||
}
|
||||
}
|
||||
|
||||
function cloneContext(context: OperationContext): OperationContext {
|
||||
assertNonEmpty(context.operationId, "operationId");
|
||||
assertNonEmpty(context.correlationId, "correlationId");
|
||||
assertNonEmpty(context.origin, "origin");
|
||||
assertActor(context.actor);
|
||||
if (context.scope) assertScope(context.scope);
|
||||
|
||||
return {
|
||||
operationId: context.operationId,
|
||||
correlationId: context.correlationId,
|
||||
origin: context.origin,
|
||||
actor: { ...context.actor },
|
||||
scope: context.scope ? { ...context.scope } : null,
|
||||
};
|
||||
}
|
||||
|
||||
function assertActor(actor: OperationActor): void {
|
||||
assertNonEmpty(actor.type, "actor.type");
|
||||
assertNullableValue(actor.id, "actor.id");
|
||||
assertNullableValue(actor.label, "actor.label");
|
||||
}
|
||||
|
||||
function assertScope(scope: OperationScope): void {
|
||||
assertNonEmpty(scope.type, "scope.type");
|
||||
assertNonEmpty(scope.id, "scope.id");
|
||||
assertNullableValue(scope.label, "scope.label");
|
||||
}
|
||||
|
||||
function assertNullableValue(value: string | null, name: string): void {
|
||||
if (value !== null) assertNonEmpty(value, name);
|
||||
}
|
||||
|
||||
function assertNonEmpty(value: string, name: string): void {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
throw new OperationContextEnrichmentError(`[OperationContext] ${name} is required.`);
|
||||
}
|
||||
}
|
||||
|
||||
function isAnonymous(actor: OperationActor): boolean {
|
||||
return actor.type === "anonymous" && actor.id === null;
|
||||
}
|
||||
|
||||
function sameActor(left: OperationActor, right: OperationActor): boolean {
|
||||
return left.type === right.type && left.id === right.id;
|
||||
}
|
||||
|
||||
function sameScope(left: OperationScope, right: OperationScope): boolean {
|
||||
return left.type === right.type && left.id === right.id;
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
export interface OperationActor {
|
||||
readonly type: string;
|
||||
readonly id: string | null;
|
||||
readonly label: string | null;
|
||||
}
|
||||
|
||||
export interface OperationScope {
|
||||
readonly type: string;
|
||||
readonly id: string;
|
||||
readonly label: string | null;
|
||||
}
|
||||
|
||||
/** Describe la operación actual, sin conocer transporte, dominio ni persistencia. */
|
||||
export interface OperationContext {
|
||||
readonly operationId: string;
|
||||
readonly correlationId: string;
|
||||
readonly origin: string;
|
||||
readonly actor: OperationActor;
|
||||
readonly scope: OperationScope | null;
|
||||
}
|
||||
|
||||
export interface IOperationContextStore {
|
||||
run<T>(context: OperationContext, callback: () => T): T;
|
||||
get(): OperationContext | null;
|
||||
setActor(actor: OperationActor): void;
|
||||
setScope(scope: OperationScope): void;
|
||||
}
|
||||
@ -1,6 +1,9 @@
|
||||
import type { Sequelize } from "sequelize";
|
||||
|
||||
import type { ISequelizeTransactionContext } from "../persistence/sequelize";
|
||||
import { SequelizeTransactionManager } from "../persistence/sequelize";
|
||||
|
||||
export const buildTransactionManager = (database: Sequelize) =>
|
||||
new SequelizeTransactionManager(database);
|
||||
export const buildTransactionManager = (
|
||||
database: Sequelize,
|
||||
transactionContext: ISequelizeTransactionContext
|
||||
) => new SequelizeTransactionManager(database, transactionContext);
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./infrastructure-errors";
|
||||
export * from "./infrastructure-repository-error";
|
||||
export * from "./infrastructure-unavailable-error";
|
||||
export * from "./nested-transaction-not-allowed-error";
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
import { InfrastructureError } from "./infrastructure-errors";
|
||||
|
||||
export class NestedTransactionNotAllowedError extends InfrastructureError {
|
||||
constructor() {
|
||||
super("[SequelizeTransactionManager] Nested transactions are not allowed.");
|
||||
}
|
||||
}
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./global-error-handler";
|
||||
export * from "./operation-context.middleware";
|
||||
export * from "./validate-request.middleware";
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
import {
|
||||
AsyncLocalStorageOperationContextStore,
|
||||
OperationAuditContextProvider,
|
||||
} from "../../context";
|
||||
|
||||
import { createOperationContextMiddleware } from "./operation-context.middleware";
|
||||
|
||||
type CapturedRequest = {
|
||||
readonly context: ReturnType<OperationAuditContextProvider["get"]>;
|
||||
readonly headers: Map<string, string>;
|
||||
};
|
||||
|
||||
async function executeRequest(correlationId?: string): Promise<CapturedRequest> {
|
||||
const store = new AsyncLocalStorageOperationContextStore();
|
||||
const provider = new OperationAuditContextProvider(store);
|
||||
const headers = new Map<string, string>();
|
||||
const middleware = createOperationContextMiddleware(store);
|
||||
|
||||
const context = await new Promise<ReturnType<OperationAuditContextProvider["get"]>>((resolve) => {
|
||||
middleware(
|
||||
{
|
||||
get: (name: string) => (name === "x-correlation-id" ? correlationId : undefined),
|
||||
} as Request,
|
||||
{
|
||||
setHeader: (name: string, value: string) => headers.set(name.toLowerCase(), value),
|
||||
} as unknown as Response,
|
||||
() => {
|
||||
Promise.resolve().then(() => resolve(provider.get()));
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return { context, headers };
|
||||
}
|
||||
|
||||
test("OperationContext HTTP genera identificadores y permanece disponible tras await", async () => {
|
||||
const result = await executeRequest();
|
||||
|
||||
assert.equal(result.context?.operation.origin, "http");
|
||||
assert.deepEqual(result.context?.actor, { type: "anonymous", id: null, label: null });
|
||||
assert.equal(result.context?.scope, null);
|
||||
assert.ok(result.context?.operation.id);
|
||||
assert.ok(result.context?.operation.correlationId);
|
||||
assert.notEqual(result.context?.operation.id, result.context?.operation.correlationId);
|
||||
assert.equal(result.headers.get("x-request-id"), result.context?.operation.id);
|
||||
assert.equal(result.headers.get("x-correlation-id"), result.context?.operation.correlationId);
|
||||
});
|
||||
|
||||
test("OperationContext HTTP propaga correlation ID válido y reemplaza uno inválido", async () => {
|
||||
const valid = await executeRequest("external-123");
|
||||
const invalid = await executeRequest("x".repeat(201));
|
||||
|
||||
assert.equal(valid.context?.operation.correlationId, "external-123");
|
||||
assert.equal(valid.headers.get("x-correlation-id"), "external-123");
|
||||
assert.ok(invalid.context?.operation.correlationId);
|
||||
assert.notEqual(invalid.context?.operation.correlationId, "x".repeat(201));
|
||||
});
|
||||
|
||||
test("OperationContext HTTP aísla requests concurrentes", async () => {
|
||||
const [first, second] = await Promise.all([
|
||||
executeRequest("correlation-a"),
|
||||
executeRequest("correlation-b"),
|
||||
]);
|
||||
|
||||
assert.equal(first.context?.operation.correlationId, "correlation-a");
|
||||
assert.equal(second.context?.operation.correlationId, "correlation-b");
|
||||
assert.notEqual(first.context?.operation.id, second.context?.operation.id);
|
||||
});
|
||||
@ -0,0 +1,62 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import type { RequestHandler } from "express";
|
||||
|
||||
import type { IOperationContextStore } from "../../context";
|
||||
|
||||
const CORRELATION_ID_HEADER = "x-correlation-id";
|
||||
const MAX_CORRELATION_ID_LENGTH = 200;
|
||||
|
||||
/**
|
||||
* Crea el scope técnico de cada petición HTTP antes de que intervengan parsers,
|
||||
* autenticación, rutas o manejo de errores.
|
||||
*/
|
||||
export function createOperationContextMiddleware(
|
||||
operationContext: IOperationContextStore
|
||||
): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
const operationId = randomUUID();
|
||||
const correlationId = getCorrelationId(req.get(CORRELATION_ID_HEADER));
|
||||
|
||||
res.setHeader("X-Request-Id", operationId);
|
||||
res.setHeader("X-Correlation-Id", correlationId);
|
||||
|
||||
operationContext.run(
|
||||
{
|
||||
operationId,
|
||||
correlationId,
|
||||
origin: "http",
|
||||
actor: {
|
||||
type: "anonymous",
|
||||
id: null,
|
||||
label: null,
|
||||
},
|
||||
scope: null,
|
||||
},
|
||||
next
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function getCorrelationId(headerValue: string | undefined): string {
|
||||
if (isValidCorrelationId(headerValue)) return headerValue.trim();
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
function isValidCorrelationId(value: string | undefined): value is string {
|
||||
if (!value) return false;
|
||||
|
||||
const trimmed = value.trim();
|
||||
return (
|
||||
trimmed.length > 0 &&
|
||||
trimmed.length <= MAX_CORRELATION_ID_LENGTH &&
|
||||
!containsControlCharacter(value)
|
||||
);
|
||||
}
|
||||
|
||||
function containsControlCharacter(value: string): boolean {
|
||||
return Array.from(value).some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return code < 32 || code === 127;
|
||||
});
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
export * from "./config";
|
||||
export * from "./context";
|
||||
export * from "./database";
|
||||
export * from "./di";
|
||||
export * from "./documents";
|
||||
|
||||
@ -3,3 +3,4 @@ export * from "./sequelize-error-translator";
|
||||
export * from "./sequelize-func";
|
||||
export * from "./sequelize-repository";
|
||||
export * from "./sequelize-transaction-manager";
|
||||
export * from "./sequelize-transaction-context";
|
||||
|
||||
@ -0,0 +1,136 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import type { Sequelize, Transaction } from "sequelize";
|
||||
|
||||
import { NestedTransactionNotAllowedError } from "../../errors";
|
||||
import { SequelizeTransactionContext } from "./sequelize-transaction-context";
|
||||
import { SequelizeTransactionManager } from "./sequelize-transaction-manager";
|
||||
|
||||
function createTransaction(): Transaction {
|
||||
return {} as Transaction;
|
||||
}
|
||||
|
||||
function createDatabase(transaction: Transaction): {
|
||||
database: Sequelize;
|
||||
getTransactionCalls(): number;
|
||||
getCommits(): number;
|
||||
getRollbacks(): number;
|
||||
} {
|
||||
let transactionCalls = 0;
|
||||
let commits = 0;
|
||||
let rollbacks = 0;
|
||||
|
||||
const database = {
|
||||
transaction: async <T>(work: (currentTransaction: Transaction) => Promise<T>): Promise<T> => {
|
||||
transactionCalls += 1;
|
||||
try {
|
||||
const result = await work(transaction);
|
||||
commits += 1;
|
||||
return result;
|
||||
} catch (error) {
|
||||
rollbacks += 1;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
} as Sequelize;
|
||||
|
||||
return {
|
||||
database,
|
||||
getTransactionCalls: () => transactionCalls,
|
||||
getCommits: () => commits,
|
||||
getRollbacks: () => rollbacks,
|
||||
};
|
||||
}
|
||||
|
||||
test("SequelizeTransactionContext propaga y restaura la transacción activa", async () => {
|
||||
const context = new SequelizeTransactionContext();
|
||||
const firstTransaction = createTransaction();
|
||||
const secondTransaction = createTransaction();
|
||||
|
||||
await context.run(firstTransaction, async () => {
|
||||
assert.equal(context.current(), firstTransaction);
|
||||
await Promise.resolve();
|
||||
assert.equal(context.current(), firstTransaction);
|
||||
|
||||
await context.run(secondTransaction, async () => {
|
||||
assert.equal(context.current(), secondTransaction);
|
||||
});
|
||||
|
||||
assert.equal(context.current(), firstTransaction);
|
||||
});
|
||||
|
||||
assert.equal(context.current(), null);
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
context.run(firstTransaction, async () => {
|
||||
throw new Error("transaction context failure");
|
||||
}),
|
||||
/transaction context failure/
|
||||
);
|
||||
assert.equal(context.current(), null);
|
||||
});
|
||||
|
||||
test("SequelizeTransactionManager publica la transacción y realiza commit al resolver", async () => {
|
||||
const context = new SequelizeTransactionContext();
|
||||
const transaction = createTransaction();
|
||||
const fakeDatabase = createDatabase(transaction);
|
||||
const manager = new SequelizeTransactionManager(fakeDatabase.database, context);
|
||||
|
||||
const result = await manager.complete(async (receivedTransaction) => {
|
||||
assert.equal(receivedTransaction, transaction);
|
||||
assert.equal(context.current(), transaction);
|
||||
await Promise.resolve();
|
||||
assert.equal(context.current(), transaction);
|
||||
return "completed";
|
||||
});
|
||||
|
||||
assert.equal(result, "completed");
|
||||
assert.equal(fakeDatabase.getCommits(), 1);
|
||||
assert.equal(fakeDatabase.getRollbacks(), 0);
|
||||
assert.equal(context.current(), null);
|
||||
});
|
||||
|
||||
test("SequelizeTransactionManager provoca rollback cuando el callback lanza o devuelve Result.fail", async () => {
|
||||
const context = new SequelizeTransactionContext();
|
||||
const fakeDatabase = createDatabase(createTransaction());
|
||||
const manager = new SequelizeTransactionManager(fakeDatabase.database, context);
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
manager.complete(async () => {
|
||||
throw new Error("work failure");
|
||||
}),
|
||||
/work failure/
|
||||
);
|
||||
await assert.rejects(
|
||||
() => manager.complete(async () => Result.fail(new Error("result failure"))),
|
||||
/result failure/
|
||||
);
|
||||
|
||||
assert.equal(fakeDatabase.getCommits(), 0);
|
||||
assert.equal(fakeDatabase.getRollbacks(), 2);
|
||||
assert.equal(context.current(), null);
|
||||
});
|
||||
|
||||
test("SequelizeTransactionManager rechaza transacciones anidadas entre managers", async () => {
|
||||
const context = new SequelizeTransactionContext();
|
||||
const fakeDatabase = createDatabase(createTransaction());
|
||||
const firstManager = new SequelizeTransactionManager(fakeDatabase.database, context);
|
||||
const secondManager = new SequelizeTransactionManager(fakeDatabase.database, context);
|
||||
|
||||
await firstManager.complete(async () => {
|
||||
await assert.rejects(
|
||||
() => secondManager.complete(async () => "unexpected"),
|
||||
NestedTransactionNotAllowedError
|
||||
);
|
||||
return "completed";
|
||||
});
|
||||
|
||||
assert.equal(fakeDatabase.getTransactionCalls(), 1);
|
||||
assert.equal(fakeDatabase.getCommits(), 1);
|
||||
assert.equal(fakeDatabase.getRollbacks(), 0);
|
||||
assert.equal(context.current(), null);
|
||||
});
|
||||
@ -0,0 +1,21 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
import type { Transaction } from "sequelize";
|
||||
|
||||
/** Contexto técnico de corta duración que expone exclusivamente la transacción activa. */
|
||||
export interface ISequelizeTransactionContext {
|
||||
run<T>(transaction: Transaction, callback: () => T): T;
|
||||
current(): Transaction | null;
|
||||
}
|
||||
|
||||
export class SequelizeTransactionContext implements ISequelizeTransactionContext {
|
||||
private readonly storage = new AsyncLocalStorage<Transaction>();
|
||||
|
||||
run<T>(transaction: Transaction, callback: () => T): T {
|
||||
return this.storage.run(transaction, callback);
|
||||
}
|
||||
|
||||
current(): Transaction | null {
|
||||
return this.storage.getStore() ?? null;
|
||||
}
|
||||
}
|
||||
@ -2,9 +2,15 @@ import { Result } from "@repo/rdx-utils";
|
||||
import { type Sequelize, Transaction } from "sequelize";
|
||||
|
||||
import { TransactionManager } from "../../database";
|
||||
import { InfrastructureError, InfrastructureUnavailableError } from "../../errors";
|
||||
import {
|
||||
InfrastructureError,
|
||||
InfrastructureUnavailableError,
|
||||
NestedTransactionNotAllowedError,
|
||||
} from "../../errors";
|
||||
import { logger } from "../../logger";
|
||||
|
||||
import type { ISequelizeTransactionContext } from "./sequelize-transaction-context";
|
||||
|
||||
export class SequelizeTransactionManager extends TransactionManager {
|
||||
protected _database: Sequelize | null = null;
|
||||
private isolationLevel: Transaction.ISOLATION_LEVELS;
|
||||
@ -26,7 +32,11 @@ export class SequelizeTransactionManager extends TransactionManager {
|
||||
throw new InfrastructureError("[SequelizeTransactionManager] Database not available");
|
||||
}
|
||||
|
||||
constructor(database: Sequelize, options?: { isolationLevel?: Transaction.ISOLATION_LEVELS }) {
|
||||
constructor(
|
||||
database: Sequelize,
|
||||
private readonly transactionContext: ISequelizeTransactionContext,
|
||||
options?: { isolationLevel?: Transaction.ISOLATION_LEVELS }
|
||||
) {
|
||||
super();
|
||||
this._database = database;
|
||||
this.isolationLevel = options?.isolationLevel ?? Transaction.ISOLATION_LEVELS.READ_COMMITTED;
|
||||
@ -52,27 +62,28 @@ export class SequelizeTransactionManager extends TransactionManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Evita transacciones anidadas según la política del TransactionManager base
|
||||
if (this._transaction) {
|
||||
// `_transaction` protege la API manual heredada; el contexto compartido cubre
|
||||
// transacciones abiertas por otros managers. La transacción funcional sigue explícita.
|
||||
if (this._transaction || this.transactionContext.current()) {
|
||||
logger.error(
|
||||
"❌ Cannot start a new transaction inside another. Nested transactions are not allowed.",
|
||||
{ label: "SequelizeTransactionManager.complete" }
|
||||
);
|
||||
throw new InfrastructureError(
|
||||
"[SequelizeTransactionManager] A transaction is already active. Nested transactions are not allowed."
|
||||
);
|
||||
throw new NestedTransactionNotAllowedError();
|
||||
}
|
||||
|
||||
try {
|
||||
// Usa la forma gestionada: si `work` resuelve => commit, si lanza excepción => rollback.
|
||||
const result = await this._database.transaction(async (t) => {
|
||||
const workResult = await work(t);
|
||||
if (workResult instanceof Result && workResult.isFailure) {
|
||||
// Forzamos rollback propagando el error
|
||||
throw workResult.error;
|
||||
}
|
||||
return workResult;
|
||||
});
|
||||
const result = await this._database.transaction(async (transaction) =>
|
||||
this.transactionContext.run(transaction, async () => {
|
||||
const workResult = await work(transaction);
|
||||
if (workResult instanceof Result && workResult.isFailure) {
|
||||
// Forzamos rollback propagando el error.
|
||||
throw workResult.error;
|
||||
}
|
||||
return workResult;
|
||||
})
|
||||
);
|
||||
return result as T;
|
||||
} catch (err) {
|
||||
const error = err as Error;
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import type { IAudit } from "@repo/rdx-audit";
|
||||
import type { Model, ModelStatic, Sequelize } from "sequelize";
|
||||
|
||||
import type { IOperationContextStore } from "../infrastructure/context";
|
||||
import type { ISequelizeTransactionContext } from "../infrastructure/persistence/sequelize";
|
||||
|
||||
export interface SequelizeModel extends Model {
|
||||
initialize: (sequelize: Sequelize) => void;
|
||||
associate?: (models: { [key: string]: ModelStatic<Model> }) => void;
|
||||
@ -12,6 +16,15 @@ export type ModuleParams = {
|
||||
*/
|
||||
getService: <T>(serviceName: string) => T;
|
||||
|
||||
/** Contexto técnico compartido para publicar la transacción Sequelize activa. */
|
||||
transactionContext: ISequelizeTransactionContext;
|
||||
|
||||
/** Contexto técnico compartido para describir la operación activa. */
|
||||
operationContext: IOperationContextStore;
|
||||
|
||||
/** Puerto transversal para registrar auditoría sin acoplar módulos a su infraestructura. */
|
||||
audit: IAudit;
|
||||
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
|
||||
@ -22,7 +22,7 @@ export type CompanyVerifactuProfileInternalDeps = {
|
||||
export function buildCompanyVerifactuProfileDependencies(
|
||||
params: ModuleParams
|
||||
): CompanyVerifactuProfileInternalDeps {
|
||||
const transactionManager = buildTransactionManager(params.database);
|
||||
const transactionManager = buildTransactionManager(params.database, params.transactionContext);
|
||||
const repository = new SequelizeCompanyVerifactuProfileRepository(
|
||||
new CompanyVerifactuProfileDomainMapper(),
|
||||
params.database
|
||||
|
||||
@ -23,10 +23,10 @@ export type IssuedInvoicesInternalDeps = {
|
||||
};
|
||||
|
||||
export function buildIssuedInvoicesDependencies(params: ModuleParams): IssuedInvoicesInternalDeps {
|
||||
const { database } = params;
|
||||
const { database, transactionContext } = params;
|
||||
|
||||
// Infrastructure
|
||||
const transactionManager = buildTransactionManager(database);
|
||||
const transactionManager = buildTransactionManager(database, transactionContext);
|
||||
const catalogs = buildCatalogs();
|
||||
/**
|
||||
* Fase 1C: IssuedInvoice queda conectada a V2.
|
||||
|
||||
@ -79,7 +79,7 @@ export type ProformasInternalDeps = {
|
||||
};
|
||||
|
||||
export function buildProformasDependencies(params: ModuleParams): ProformasInternalDeps {
|
||||
const { database } = params;
|
||||
const { database, transactionContext } = params;
|
||||
|
||||
const catalogs = resolveProformaCatalogsDeps(params);
|
||||
const companiesServices = params.getService<ICompanyPublicServices>("companies:general");
|
||||
@ -87,7 +87,7 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
|
||||
const documentSeriesServices =
|
||||
params.getService<DocumentSeriesPublicServicesType>("document-series:general");
|
||||
|
||||
const transactionManager = buildTransactionManager(database);
|
||||
const transactionManager = buildTransactionManager(database, transactionContext);
|
||||
/**
|
||||
* Fase 1C: el wiring operativo principal ya usa V2.
|
||||
* Requiere que existan `proformas`, `proforma_items` y `proforma_taxes` en la BD real.
|
||||
|
||||
@ -42,12 +42,12 @@ export type CustomersInternalDeps = {
|
||||
};
|
||||
|
||||
export function buildCustomersDependencies(params: ModuleParams): CustomersInternalDeps {
|
||||
const { database } = params;
|
||||
const { database, transactionContext } = params;
|
||||
|
||||
const catalogs = resolveCustomerCatalogsDeps(params);
|
||||
const companies = resolveCustomerCompanyDeps(params);
|
||||
|
||||
const transactionManager = buildTransactionManager(database);
|
||||
const transactionManager = buildTransactionManager(database, transactionContext);
|
||||
const persistenceMappers = buildCustomerPersistenceMappers();
|
||||
|
||||
const repository = buildCustomerRepository({ database, mappers: persistenceMappers });
|
||||
|
||||
@ -15,9 +15,9 @@ export type DocumentSeriesInternalDeps = {
|
||||
};
|
||||
|
||||
export function buildDocumentSeriesDependencies(params: ModuleParams): DocumentSeriesInternalDeps {
|
||||
const { database } = params;
|
||||
const { database, transactionContext } = params;
|
||||
|
||||
const transactionManager = buildTransactionManager(database);
|
||||
const transactionManager = buildTransactionManager(database, transactionContext);
|
||||
const mapper = new SequelizeDocumentSeriesDomainMapper();
|
||||
const repository: IDocumentSeriesRepository = new SequelizeDocumentSeriesRepository(
|
||||
mapper,
|
||||
|
||||
@ -21,10 +21,10 @@ import {
|
||||
} from "./controllers";
|
||||
|
||||
export const documentSeriesRouter = (params: StartParams) => {
|
||||
const { app, config, getInternal, database } = params;
|
||||
const { app, config, getInternal, database, transactionContext } = params;
|
||||
const deps = getInternal<DocumentSeriesInternalDeps>("document-series");
|
||||
const router: Router = Router({ mergeParams: true });
|
||||
const transactionManager = buildTransactionManager(database);
|
||||
const transactionManager = buildTransactionManager(database, transactionContext);
|
||||
|
||||
router.use(...requireIdentityTenant(params));
|
||||
|
||||
|
||||
@ -26,12 +26,12 @@ export type FactugesInternalDeps = {
|
||||
};
|
||||
|
||||
export function buildFactugesDependencies(params: SetupParams): FactugesInternalDeps {
|
||||
const { database } = params;
|
||||
const { database, transactionContext } = params;
|
||||
|
||||
const catalogs = resolveFactuGESCatalogsDeps(params);
|
||||
|
||||
// Infrastructure
|
||||
const transactionManager = buildTransactionManager(database);
|
||||
const transactionManager = buildTransactionManager(database, transactionContext);
|
||||
const inputMappers = buildFactugesInputMappers();
|
||||
const persistenceMappers = buildFactuGESPersistenceMappers();
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type { IOperationContextStore } from "@erp/core/api";
|
||||
import type { Result } from "@repo/rdx-utils";
|
||||
import type { RequestHandler } from "express";
|
||||
|
||||
@ -15,9 +16,12 @@ export interface IIdentityTenantCompanyFinder {
|
||||
}
|
||||
|
||||
export interface IIdentityAuthPublicServices {
|
||||
authenticatedOnly(): AuthenticatedOnlyMiddlewares;
|
||||
authenticatedOnly(params: {
|
||||
operationContext: IOperationContextStore;
|
||||
}): AuthenticatedOnlyMiddlewares;
|
||||
tenantRequired(params: {
|
||||
companyAccessChecker: IIdentityCompanyAccessChecker;
|
||||
companyFinder: IIdentityTenantCompanyFinder;
|
||||
operationContext: IOperationContextStore;
|
||||
}): TenantRequiredMiddlewares;
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import type { IOperationContextStore } from "@erp/core/api";
|
||||
|
||||
import type {
|
||||
IIdentityAuthPublicServices,
|
||||
IIdentityCompanyAccessChecker,
|
||||
@ -17,16 +19,20 @@ class IdentityAuthPublicServices implements IIdentityAuthPublicServices {
|
||||
private readonly deps: IdentityInternalDeps["auth"]["authenticateUserDependencies"]
|
||||
) {}
|
||||
|
||||
public authenticatedOnly() {
|
||||
return [authenticateUser(this.deps), requireAuthenticated()] as const;
|
||||
public authenticatedOnly(params: { operationContext: IOperationContextStore }) {
|
||||
return [
|
||||
authenticateUser({ ...this.deps, operationContext: params.operationContext }),
|
||||
requireAuthenticated(),
|
||||
] as const;
|
||||
}
|
||||
|
||||
public tenantRequired(params: {
|
||||
companyAccessChecker: IIdentityCompanyAccessChecker;
|
||||
companyFinder: IIdentityTenantCompanyFinder;
|
||||
operationContext: IOperationContextStore;
|
||||
}) {
|
||||
return [
|
||||
authenticateUser(this.deps),
|
||||
authenticateUser({ ...this.deps, operationContext: params.operationContext }),
|
||||
requireAuthenticated(),
|
||||
requireCompanyContext(params),
|
||||
] as const;
|
||||
|
||||
@ -71,9 +71,9 @@ export type IdentityInternalDeps = {
|
||||
};
|
||||
|
||||
export const buildIdentityDependencies = (params: ModuleParams): IdentityInternalDeps => {
|
||||
const { database, config } = params;
|
||||
const { database, config, transactionContext } = params;
|
||||
|
||||
const transactionManager = buildTransactionManager(database);
|
||||
const transactionManager = buildTransactionManager(database, transactionContext);
|
||||
|
||||
const accountRepository = new SequelizeAccountRepository(
|
||||
new SequelizeAccountDomainMapper(),
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
AsyncLocalStorageOperationContextStore,
|
||||
OperationAuditContextProvider,
|
||||
type RequestWithAuth,
|
||||
} from "@erp/core/api";
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
import { authenticateUser } from "./authenticate-user.middleware";
|
||||
|
||||
test("authenticateUser enriquece actor solo después de validar la identidad", async () => {
|
||||
const operationContext = new AsyncLocalStorageOperationContextStore();
|
||||
const auditContextProvider = new OperationAuditContextProvider(operationContext);
|
||||
const accountId = UniqueID.create("11111111-1111-4111-8111-111111111111").data;
|
||||
const request = {
|
||||
get: (name: string) => (name === "authorization" ? "Bearer valid-token" : undefined),
|
||||
} as Request;
|
||||
const middleware = authenticateUser({
|
||||
accessTokenVerifier: {
|
||||
verify: async () =>
|
||||
Result.ok({ accountId: accountId.toPrimitive(), email: "ada@example.com" }),
|
||||
},
|
||||
accountRepository: {
|
||||
findById: async () =>
|
||||
Result.ok(
|
||||
Maybe.some({
|
||||
id: accountId,
|
||||
canAuthenticate: () => Result.ok(),
|
||||
})
|
||||
),
|
||||
} as never,
|
||||
operationContext,
|
||||
});
|
||||
|
||||
await operationContext.run(
|
||||
{
|
||||
operationId: "operation-1",
|
||||
correlationId: "correlation-1",
|
||||
origin: "http",
|
||||
actor: { type: "anonymous", id: null, label: null },
|
||||
scope: null,
|
||||
},
|
||||
async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
middleware(request, {} as Response, resolve);
|
||||
});
|
||||
|
||||
assert.deepEqual(auditContextProvider.get()?.actor, {
|
||||
type: "user",
|
||||
id: accountId.toPrimitive(),
|
||||
label: "ada@example.com",
|
||||
});
|
||||
assert.equal((request as RequestWithAuth).user.userId, accountId);
|
||||
}
|
||||
);
|
||||
});
|
||||
@ -1,5 +1,6 @@
|
||||
import {
|
||||
ExpressController,
|
||||
type IOperationContextStore,
|
||||
type RequestWithAuth,
|
||||
UnauthorizedApiError,
|
||||
} from "@erp/core/api";
|
||||
@ -24,6 +25,7 @@ function parseBearerToken(authorization?: string): string | undefined {
|
||||
export function authenticateUser(params: {
|
||||
accessTokenVerifier: IAccessTokenVerifier;
|
||||
accountRepository: IAccountRepository;
|
||||
operationContext: IOperationContextStore;
|
||||
}) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
@ -66,6 +68,12 @@ export function authenticateUser(params: {
|
||||
roles: [],
|
||||
};
|
||||
|
||||
params.operationContext.setActor({
|
||||
type: "user",
|
||||
id: account.id.toPrimitive(),
|
||||
label: emailResult.data.toString(),
|
||||
});
|
||||
|
||||
return next();
|
||||
} catch {
|
||||
return ExpressController.errorResponse(new UnauthorizedApiError("Unauthorized"), req, res);
|
||||
|
||||
@ -1,9 +1,13 @@
|
||||
import type { IOperationContextStore } from "@erp/core/api";
|
||||
|
||||
import type { IAccessTokenVerifier, IAccountRepository } from "../../../application";
|
||||
|
||||
import { authenticateUser } from "./authenticate-user.middleware";
|
||||
|
||||
export function identityAuthenticateRequest(params: {
|
||||
accessTokenVerifier: IAccessTokenVerifier;
|
||||
accountRepository: IAccountRepository;
|
||||
operationContext: IOperationContextStore;
|
||||
}) {
|
||||
return authenticateUser(params);
|
||||
}
|
||||
|
||||
@ -0,0 +1,88 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
AsyncLocalStorageOperationContextStore,
|
||||
OperationAuditContextProvider,
|
||||
type RequestWithAuth,
|
||||
} from "@erp/core/api";
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
import { requireCompanyContext } from "./require-company-context.middleware";
|
||||
|
||||
test("requireCompanyContext establece scope después de validar membership", async () => {
|
||||
const operationContext = new AsyncLocalStorageOperationContextStore();
|
||||
const auditContextProvider = new OperationAuditContextProvider(operationContext);
|
||||
const accountId = UniqueID.create("11111111-1111-4111-8111-111111111111").data;
|
||||
const companyId = "22222222-2222-4222-8222-222222222222";
|
||||
const request = {
|
||||
user: { userId: accountId },
|
||||
get: (name: string) => (name === "X-Company-Id" ? companyId : undefined),
|
||||
} as RequestWithAuth;
|
||||
const middleware = requireCompanyContext({
|
||||
companyAccessChecker: {
|
||||
canAccessCompany: async () => Result.ok(true),
|
||||
},
|
||||
companyFinder: {
|
||||
existsActiveById: async () => Result.ok(true),
|
||||
},
|
||||
operationContext,
|
||||
});
|
||||
|
||||
await operationContext.run(
|
||||
{
|
||||
operationId: "operation-1",
|
||||
correlationId: "correlation-1",
|
||||
origin: "http",
|
||||
actor: { type: "user", id: accountId.toPrimitive(), label: "ada@example.com" },
|
||||
scope: null,
|
||||
},
|
||||
async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
middleware(request as Request, {} as Response, resolve);
|
||||
});
|
||||
|
||||
assert.deepEqual(auditContextProvider.get()?.scope, {
|
||||
type: "company",
|
||||
id: companyId,
|
||||
label: null,
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("requireCompanyContext no establece scope cuando la membership no está autorizada", async () => {
|
||||
const operationContext = new AsyncLocalStorageOperationContextStore();
|
||||
const accountId = UniqueID.create("11111111-1111-4111-8111-111111111111").data;
|
||||
const request = {
|
||||
user: { userId: accountId },
|
||||
get: () => "22222222-2222-4222-8222-222222222222",
|
||||
} as RequestWithAuth;
|
||||
const response = {
|
||||
type: () => response,
|
||||
status: () => response,
|
||||
json: () => response,
|
||||
} as Response;
|
||||
const middleware = requireCompanyContext({
|
||||
companyAccessChecker: {
|
||||
canAccessCompany: async () => Result.ok(false),
|
||||
},
|
||||
operationContext,
|
||||
});
|
||||
|
||||
await operationContext.run(
|
||||
{
|
||||
operationId: "operation-1",
|
||||
correlationId: "correlation-1",
|
||||
origin: "http",
|
||||
actor: { type: "user", id: accountId.toPrimitive(), label: "ada@example.com" },
|
||||
scope: null,
|
||||
},
|
||||
async () => {
|
||||
await middleware(request as Request, response, () => undefined);
|
||||
assert.equal(operationContext.get()?.scope, null);
|
||||
}
|
||||
);
|
||||
});
|
||||
@ -1,20 +1,26 @@
|
||||
import {
|
||||
ExpressController,
|
||||
ForbiddenApiError,
|
||||
type IOperationContextStore,
|
||||
InternalApiError,
|
||||
type RequestWithAuth,
|
||||
ValidationApiError,
|
||||
} from "@erp/core/api";
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
|
||||
import type { IIdentityCompanyAccessChecker, IIdentityTenantCompanyFinder } from "../../../application";
|
||||
import type {
|
||||
IIdentityCompanyAccessChecker,
|
||||
IIdentityTenantCompanyFinder,
|
||||
} from "../../../application";
|
||||
|
||||
const COMPANY_HEADER_NAME = "X-Company-Id";
|
||||
|
||||
export function requireCompanyContext(params: {
|
||||
companyAccessChecker: IIdentityCompanyAccessChecker;
|
||||
companyFinder?: IIdentityTenantCompanyFinder;
|
||||
operationContext: IOperationContextStore;
|
||||
}) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = (req as RequestWithAuth).user;
|
||||
@ -71,31 +77,39 @@ export function requireCompanyContext(params: {
|
||||
);
|
||||
}
|
||||
|
||||
// Algunos módulos solo necesitan validar membership y no pueden depender
|
||||
// de companies sin introducir un ciclo de módulos.
|
||||
if (params.companyFinder) {
|
||||
const companyExistsResult = await params.companyFinder.existsActiveById({
|
||||
companyId,
|
||||
});
|
||||
if (companyExistsResult.isFailure) {
|
||||
return ExpressController.errorResponse(
|
||||
new InternalApiError(companyExistsResult.error.message),
|
||||
req,
|
||||
res
|
||||
);
|
||||
}
|
||||
const companyExistsResult = await findActiveCompany(params.companyFinder, companyId);
|
||||
if (companyExistsResult.isFailure) {
|
||||
return ExpressController.errorResponse(
|
||||
new InternalApiError(companyExistsResult.error.message),
|
||||
req,
|
||||
res
|
||||
);
|
||||
}
|
||||
|
||||
if (!companyExistsResult.data) {
|
||||
return ExpressController.errorResponse(
|
||||
new ForbiddenApiError("Company context required"),
|
||||
req,
|
||||
res
|
||||
);
|
||||
}
|
||||
if (!companyExistsResult.data) {
|
||||
return ExpressController.errorResponse(
|
||||
new ForbiddenApiError("Company context required"),
|
||||
req,
|
||||
res
|
||||
);
|
||||
}
|
||||
|
||||
user.companyId = companyId;
|
||||
params.operationContext.setScope({
|
||||
type: "company",
|
||||
id: companyId.toPrimitive(),
|
||||
label: null,
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
async function findActiveCompany(
|
||||
companyFinder: IIdentityTenantCompanyFinder | undefined,
|
||||
companyId: UniqueID
|
||||
): Promise<Result<boolean, Error>> {
|
||||
// Algunos módulos solo validan membership para no introducir un ciclo con companies.
|
||||
if (!companyFinder) return Result.ok(true);
|
||||
return await companyFinder.existsActiveById({ companyId });
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ import type { StartParams } from "@erp/core/api";
|
||||
import type { RequestHandler } from "express";
|
||||
|
||||
import type { IIdentityPublicServices } from "../../../application";
|
||||
|
||||
import { requireCompanyContext } from "./require-company-context.middleware";
|
||||
|
||||
type CompaniesGeneralServices = {
|
||||
@ -15,7 +16,9 @@ type CompaniesGeneralServices = {
|
||||
|
||||
export function requireIdentityAuthenticated(params: StartParams): RequestHandler[] {
|
||||
const identityServices = params.getService<IIdentityPublicServices>("identity:general");
|
||||
return [...identityServices.auth.authenticatedOnly()];
|
||||
return [
|
||||
...identityServices.auth.authenticatedOnly({ operationContext: params.operationContext }),
|
||||
];
|
||||
}
|
||||
|
||||
export function requireIdentityTenant(params: StartParams): RequestHandler[] {
|
||||
@ -25,6 +28,7 @@ export function requireIdentityTenant(params: StartParams): RequestHandler[] {
|
||||
...identityServices.auth.tenantRequired({
|
||||
companyAccessChecker: identityServices.companyAccess,
|
||||
companyFinder: companiesServices.finder,
|
||||
operationContext: params.operationContext,
|
||||
}),
|
||||
];
|
||||
}
|
||||
@ -33,9 +37,10 @@ export function requireIdentityTenantMembership(params: StartParams): RequestHan
|
||||
const identityServices = params.getService<IIdentityPublicServices>("identity:general");
|
||||
|
||||
return [
|
||||
...identityServices.auth.authenticatedOnly(),
|
||||
...identityServices.auth.authenticatedOnly({ operationContext: params.operationContext }),
|
||||
requireCompanyContext({
|
||||
companyAccessChecker: identityServices.companyAccess,
|
||||
operationContext: params.operationContext,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@ -29,10 +29,10 @@ export type SuppliersInternalDeps = {
|
||||
};
|
||||
|
||||
export function buildSuppliersDependencies(params: ModuleParams): SuppliersInternalDeps {
|
||||
const { database } = params;
|
||||
const { database, transactionContext } = params;
|
||||
|
||||
// Infrastructure
|
||||
const transactionManager = buildTransactionManager(database);
|
||||
const transactionManager = buildTransactionManager(database, transactionContext);
|
||||
const persistenceMappers = buildSupplierPersistenceMappers();
|
||||
|
||||
const repository = buildSupplierRepository({ database, mappers: persistenceMappers });
|
||||
|
||||
27
packages/rdx-audit/package.json
Normal file
27
packages/rdx-audit/package.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "@repo/rdx-audit",
|
||||
"version": "0.10.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"test": "node --import tsx --test",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"check": "biome check .",
|
||||
"lint": "biome lint .",
|
||||
"clean": "rimraf .turbo node_modules dist"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@types/node": "^25.6.0",
|
||||
"rimraf": "^6.1.3",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/rdx-utils": "workspace:*"
|
||||
}
|
||||
}
|
||||
11
packages/rdx-audit/src/audit-state.types.ts
Normal file
11
packages/rdx-audit/src/audit-state.types.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export type AuditScalar = string | number | boolean | null;
|
||||
|
||||
export type AuditValue = AuditScalar | AuditObject | AuditArray;
|
||||
|
||||
export interface AuditObject {
|
||||
readonly [key: string]: AuditValue;
|
||||
}
|
||||
|
||||
export type AuditArray = readonly AuditValue[];
|
||||
|
||||
export type AuditState = Readonly<Record<string, AuditValue>>;
|
||||
173
packages/rdx-audit/src/audit.test.ts
Normal file
173
packages/rdx-audit/src/audit.test.ts
Normal file
@ -0,0 +1,173 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { Audit } from "./audit";
|
||||
import type {
|
||||
AuditContext,
|
||||
AuditEntry,
|
||||
IAuditContextProvider,
|
||||
IAuditStore,
|
||||
RecordAuditEventShortcutInput,
|
||||
RecordAuditMutationShortcutInput,
|
||||
} from "./audit.types";
|
||||
import {
|
||||
AuditContextUnavailableError,
|
||||
AuditStoreError,
|
||||
InvalidAuditInputError,
|
||||
InvalidAuditStateError,
|
||||
} from "./errors";
|
||||
|
||||
const context: AuditContext = {
|
||||
operation: { id: "operation-1", correlationId: "correlation-1", origin: "http" },
|
||||
actor: { type: "user", id: "user-1", label: "Ada" },
|
||||
scope: { type: "company", id: "company-1", label: "Acme" },
|
||||
};
|
||||
|
||||
class StubContextProvider implements IAuditContextProvider {
|
||||
constructor(private readonly value: AuditContext | null) {}
|
||||
|
||||
get(): AuditContext | null {
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryAuditStore implements IAuditStore {
|
||||
readonly entries: AuditEntry[] = [];
|
||||
readonly error: AuditStoreError | null;
|
||||
|
||||
constructor(error: AuditStoreError | null = null) {
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
async append(entry: AuditEntry) {
|
||||
if (this.error) return Result.fail(this.error);
|
||||
this.entries.push(entry);
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
|
||||
function buildAudit(store = new InMemoryAuditStore(), provider: IAuditContextProvider = new StubContextProvider(context)) {
|
||||
return {
|
||||
audit: new Audit({
|
||||
source: { type: "application", id: "test-app" },
|
||||
contextProvider: provider,
|
||||
store,
|
||||
generateId: () => "audit-1",
|
||||
now: () => new Date("2026-08-24T17:30:12.123Z"),
|
||||
}),
|
||||
store,
|
||||
};
|
||||
}
|
||||
|
||||
const eventInput: RecordAuditEventShortcutInput = {
|
||||
action: "document.downloaded",
|
||||
target: { type: "document", id: "document-1", label: "invoice.pdf" },
|
||||
};
|
||||
|
||||
const mutationInput: RecordAuditMutationShortcutInput = {
|
||||
action: "customer.updated",
|
||||
target: { type: "customer", id: "customer-1", label: "Ada Lovelace" },
|
||||
before: { name: "Ada", status: "active" },
|
||||
after: { name: "Grace", status: "active" },
|
||||
};
|
||||
|
||||
test("records an event with source, context, deterministic id and timestamp", async () => {
|
||||
const { audit, store } = buildAudit();
|
||||
const result = await audit.recordEvent(eventInput);
|
||||
|
||||
assert.equal(result.isSuccess, true);
|
||||
assert.equal(store.entries.length, 1);
|
||||
assert.deepEqual(store.entries[0], {
|
||||
id: "audit-1",
|
||||
occurredAt: "2026-08-24T17:30:12.123Z",
|
||||
source: { type: "application", id: "test-app" },
|
||||
operation: context.operation,
|
||||
actor: context.actor,
|
||||
scope: context.scope,
|
||||
kind: "event",
|
||||
action: "document.downloaded",
|
||||
target: eventInput.target,
|
||||
changes: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("records a mutation with a deterministic diff", async () => {
|
||||
const { audit, store } = buildAudit();
|
||||
const result = await audit.recordMutation(mutationInput);
|
||||
|
||||
assert.equal(result.isSuccess, true);
|
||||
assert.equal(store.entries.length, 1);
|
||||
assert.deepEqual(store.entries[0]?.kind, "mutation");
|
||||
assert.deepEqual(store.entries[0]?.changes, [
|
||||
{ path: "name", kind: "change", before: "Ada", after: "Grace" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not append mutation no-ops", async () => {
|
||||
const { audit, store } = buildAudit();
|
||||
const result = await audit.recordMutation({ ...mutationInput, after: mutationInput.before });
|
||||
|
||||
assert.equal(result.isSuccess, true);
|
||||
assert.equal(store.entries.length, 0);
|
||||
});
|
||||
|
||||
test("honours mutation atomic paths", async () => {
|
||||
const { audit, store } = buildAudit();
|
||||
const result = await audit.recordMutation({
|
||||
...mutationInput,
|
||||
before: { paymentTerm: { id: "PT1", label: "30 days" } },
|
||||
after: { paymentTerm: { id: "PT2", label: "60 days" } },
|
||||
atomicPaths: ["paymentTerm"],
|
||||
});
|
||||
|
||||
assert.equal(result.isSuccess, true);
|
||||
assert.deepEqual(store.entries[0]?.changes, [
|
||||
{
|
||||
path: "paymentTerm",
|
||||
kind: "change",
|
||||
before: { id: "PT1", label: "30 days" },
|
||||
after: { id: "PT2", label: "60 days" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("shortcuts are equivalent to the canonical record method", async () => {
|
||||
const first = buildAudit();
|
||||
const second = buildAudit();
|
||||
await first.audit.recordEvent(eventInput);
|
||||
await second.audit.record({ kind: "event", ...eventInput });
|
||||
assert.deepEqual(first.store.entries, second.store.entries);
|
||||
|
||||
const third = buildAudit();
|
||||
const fourth = buildAudit();
|
||||
await third.audit.recordMutation(mutationInput);
|
||||
await fourth.audit.record({ kind: "mutation", ...mutationInput });
|
||||
assert.deepEqual(third.store.entries, fourth.store.entries);
|
||||
});
|
||||
|
||||
test("propagates store failures and fails without context", async () => {
|
||||
const storeError = new AuditStoreError("store unavailable");
|
||||
const failedStore = buildAudit(new InMemoryAuditStore(storeError));
|
||||
const storeResult = await failedStore.audit.recordEvent(eventInput);
|
||||
assert.equal(storeResult.isFailure, true);
|
||||
assert.equal(storeResult.error, storeError);
|
||||
|
||||
const missingContext = buildAudit(new InMemoryAuditStore(), new StubContextProvider(null));
|
||||
const contextResult = await missingContext.audit.recordEvent(eventInput);
|
||||
assert.equal(contextResult.isFailure, true);
|
||||
assert.ok(contextResult.error instanceof AuditContextUnavailableError);
|
||||
});
|
||||
|
||||
test("rejects invalid actions, targets and mutation states", async () => {
|
||||
const { audit } = buildAudit();
|
||||
const actionResult = await audit.recordEvent({ ...eventInput, action: "Document.Downloaded" });
|
||||
assert.ok(actionResult.error instanceof InvalidAuditInputError);
|
||||
|
||||
const targetResult = await audit.recordMutation({ ...mutationInput, target: { ...mutationInput.target, id: "" } });
|
||||
assert.ok(targetResult.error instanceof InvalidAuditInputError);
|
||||
|
||||
const stateResult = await audit.recordMutation({ ...mutationInput, before: { bad: new Date() as never } });
|
||||
assert.ok(stateResult.error instanceof InvalidAuditStateError);
|
||||
});
|
||||
120
packages/rdx-audit/src/audit.ts
Normal file
120
packages/rdx-audit/src/audit.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type {
|
||||
AuditContext,
|
||||
AuditEntry,
|
||||
AuditEventEntry,
|
||||
AuditMutationEntry,
|
||||
AuditSource,
|
||||
IAudit,
|
||||
IAuditContextProvider,
|
||||
IAuditStore,
|
||||
RecordAuditEventShortcutInput,
|
||||
RecordAuditInput,
|
||||
RecordAuditMutationShortcutInput,
|
||||
} from "./audit.types";
|
||||
import { AuditContextUnavailableError, AuditError, AuditStoreError } from "./errors";
|
||||
import { diffAuditState } from "./internal/audit-diff";
|
||||
import {
|
||||
validateAtomicPaths,
|
||||
validateContext,
|
||||
validateRecordInput,
|
||||
validateSource,
|
||||
} from "./internal/audit-input-validator";
|
||||
import { validateAuditState } from "./internal/audit-state-validator";
|
||||
|
||||
export interface AuditDependencies {
|
||||
readonly source: AuditSource;
|
||||
readonly contextProvider: IAuditContextProvider;
|
||||
readonly store: IAuditStore;
|
||||
readonly generateId?: () => string;
|
||||
readonly now?: () => Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementación portable append-only. Los shortcuts delegan en `record` para
|
||||
* mantener una única ruta de validación, diff y persistencia.
|
||||
*/
|
||||
export class Audit implements IAudit {
|
||||
private readonly generateId: () => string;
|
||||
private readonly now: () => Date;
|
||||
|
||||
constructor(private readonly deps: AuditDependencies) {
|
||||
validateSource(deps.source);
|
||||
this.generateId = deps.generateId ?? randomUUID;
|
||||
this.now = deps.now ?? (() => new Date());
|
||||
}
|
||||
|
||||
async record(input: RecordAuditInput): Promise<Result<void, AuditError>> {
|
||||
try {
|
||||
validateRecordInput(input);
|
||||
|
||||
if (input.kind === "mutation") {
|
||||
validateAuditState(input.before, "before");
|
||||
validateAuditState(input.after, "after");
|
||||
const atomicPaths = validateAtomicPaths(input.atomicPaths);
|
||||
const changes = diffAuditState(input.before, input.after, atomicPaths);
|
||||
if (changes.length === 0) return Result.ok();
|
||||
|
||||
const context = this.getContext();
|
||||
const entry: AuditMutationEntry = {
|
||||
...this.buildBaseEntry(context, input.action),
|
||||
kind: "mutation",
|
||||
target: input.target,
|
||||
changes,
|
||||
};
|
||||
return await this.append(entry);
|
||||
}
|
||||
|
||||
const context = this.getContext();
|
||||
const entry: AuditEventEntry = {
|
||||
...this.buildBaseEntry(context, input.action),
|
||||
kind: "event",
|
||||
target: input.target,
|
||||
changes: [],
|
||||
};
|
||||
return await this.append(entry);
|
||||
} catch (error: unknown) {
|
||||
return Result.fail(this.toAuditError(error));
|
||||
}
|
||||
}
|
||||
|
||||
async recordEvent(input: RecordAuditEventShortcutInput): Promise<Result<void, AuditError>> {
|
||||
return await this.record({ kind: "event", ...input });
|
||||
}
|
||||
|
||||
async recordMutation(input: RecordAuditMutationShortcutInput): Promise<Result<void, AuditError>> {
|
||||
return await this.record({ kind: "mutation", ...input });
|
||||
}
|
||||
|
||||
private getContext(): AuditContext {
|
||||
const context = this.deps.contextProvider.get();
|
||||
if (!context) throw new AuditContextUnavailableError();
|
||||
validateContext(context);
|
||||
return context;
|
||||
}
|
||||
|
||||
private buildBaseEntry(context: AuditContext, action: string) {
|
||||
return {
|
||||
id: this.generateId(),
|
||||
occurredAt: this.now().toISOString(),
|
||||
source: this.deps.source,
|
||||
operation: context.operation,
|
||||
actor: context.actor,
|
||||
scope: context.scope,
|
||||
action,
|
||||
};
|
||||
}
|
||||
|
||||
private async append(entry: AuditEntry): Promise<Result<void, AuditError>> {
|
||||
const result = await this.deps.store.append(entry);
|
||||
return result.isFailure ? Result.fail(result.error) : Result.ok();
|
||||
}
|
||||
|
||||
private toAuditError(error: unknown): AuditError {
|
||||
if (error instanceof AuditError) return error;
|
||||
return new AuditStoreError(error instanceof Error ? error.message : "Audit store failed.");
|
||||
}
|
||||
}
|
||||
114
packages/rdx-audit/src/audit.types.ts
Normal file
114
packages/rdx-audit/src/audit.types.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import type { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { AuditError, AuditStoreError } from "./errors";
|
||||
import type { AuditState, AuditValue } from "./audit-state.types";
|
||||
|
||||
export interface AuditSource {
|
||||
readonly type: string;
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export interface AuditOperation {
|
||||
readonly id: string;
|
||||
readonly correlationId: string;
|
||||
readonly origin: string;
|
||||
}
|
||||
|
||||
export interface AuditActor {
|
||||
readonly type: string;
|
||||
readonly id: string | null;
|
||||
readonly label: string | null;
|
||||
}
|
||||
|
||||
export interface AuditScope {
|
||||
readonly type: string;
|
||||
readonly id: string;
|
||||
readonly label: string | null;
|
||||
}
|
||||
|
||||
export interface AuditContext {
|
||||
readonly operation: AuditOperation;
|
||||
readonly actor: AuditActor;
|
||||
readonly scope: AuditScope | null;
|
||||
}
|
||||
|
||||
export interface IAuditContextProvider {
|
||||
get(): AuditContext | null;
|
||||
}
|
||||
|
||||
export interface AuditEventTarget {
|
||||
readonly type: string;
|
||||
readonly id: string | null;
|
||||
readonly label: string | null;
|
||||
}
|
||||
|
||||
export interface AuditMutationTarget {
|
||||
readonly type: string;
|
||||
readonly id: string;
|
||||
readonly label: string | null;
|
||||
}
|
||||
|
||||
export interface RecordAuditEventInput {
|
||||
readonly kind: "event";
|
||||
readonly action: string;
|
||||
readonly target: AuditEventTarget;
|
||||
}
|
||||
|
||||
export interface RecordAuditMutationInput {
|
||||
readonly kind: "mutation";
|
||||
readonly action: string;
|
||||
readonly target: AuditMutationTarget;
|
||||
readonly before: AuditState;
|
||||
readonly after: AuditState;
|
||||
readonly atomicPaths?: readonly string[];
|
||||
}
|
||||
|
||||
export type RecordAuditInput = RecordAuditEventInput | RecordAuditMutationInput;
|
||||
|
||||
export type RecordAuditEventShortcutInput = Omit<RecordAuditEventInput, "kind">;
|
||||
|
||||
export type RecordAuditMutationShortcutInput = Omit<RecordAuditMutationInput, "kind">;
|
||||
|
||||
export type AuditChangeKind = "set" | "unset" | "change";
|
||||
|
||||
export interface AuditChange {
|
||||
readonly path: string;
|
||||
readonly kind: AuditChangeKind;
|
||||
readonly before: AuditValue | null;
|
||||
readonly after: AuditValue | null;
|
||||
}
|
||||
|
||||
interface AuditEntryBase {
|
||||
readonly id: string;
|
||||
readonly occurredAt: string;
|
||||
readonly source: AuditSource;
|
||||
readonly operation: AuditOperation;
|
||||
readonly actor: AuditActor;
|
||||
readonly scope: AuditScope | null;
|
||||
readonly action: string;
|
||||
}
|
||||
|
||||
export interface AuditEventEntry extends AuditEntryBase {
|
||||
readonly kind: "event";
|
||||
readonly target: AuditEventTarget;
|
||||
readonly changes: readonly [];
|
||||
}
|
||||
|
||||
export interface AuditMutationEntry extends AuditEntryBase {
|
||||
readonly kind: "mutation";
|
||||
readonly target: AuditMutationTarget;
|
||||
readonly changes: readonly AuditChange[];
|
||||
}
|
||||
|
||||
export type AuditEntry = AuditEventEntry | AuditMutationEntry;
|
||||
|
||||
export interface IAuditStore {
|
||||
append(entry: AuditEntry): Promise<Result<void, AuditStoreError>>;
|
||||
}
|
||||
|
||||
/** Registra eventos y mutaciones mediante un puerto append-only. */
|
||||
export interface IAudit {
|
||||
record(input: RecordAuditInput): Promise<Result<void, AuditError>>;
|
||||
recordEvent(input: RecordAuditEventShortcutInput): Promise<Result<void, AuditError>>;
|
||||
recordMutation(input: RecordAuditMutationShortcutInput): Promise<Result<void, AuditError>>;
|
||||
}
|
||||
18
packages/rdx-audit/src/errors/audit-error.ts
Normal file
18
packages/rdx-audit/src/errors/audit-error.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export abstract class AuditError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = new.target.name;
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidAuditInputError extends AuditError {}
|
||||
|
||||
export class InvalidAuditStateError extends AuditError {}
|
||||
|
||||
export class AuditContextUnavailableError extends AuditError {
|
||||
constructor() {
|
||||
super("Audit context is unavailable.");
|
||||
}
|
||||
}
|
||||
|
||||
export class AuditStoreError extends AuditError {}
|
||||
1
packages/rdx-audit/src/errors/index.ts
Normal file
1
packages/rdx-audit/src/errors/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./audit-error";
|
||||
31
packages/rdx-audit/src/index.ts
Normal file
31
packages/rdx-audit/src/index.ts
Normal file
@ -0,0 +1,31 @@
|
||||
export { Audit, type AuditDependencies } from "./audit";
|
||||
export type { AuditArray, AuditObject, AuditScalar, AuditState, AuditValue } from "./audit-state.types";
|
||||
export type {
|
||||
AuditActor,
|
||||
AuditChange,
|
||||
AuditChangeKind,
|
||||
AuditContext,
|
||||
AuditEntry,
|
||||
AuditEventEntry,
|
||||
AuditEventTarget,
|
||||
AuditMutationEntry,
|
||||
AuditMutationTarget,
|
||||
AuditOperation,
|
||||
AuditScope,
|
||||
AuditSource,
|
||||
IAudit,
|
||||
IAuditContextProvider,
|
||||
IAuditStore,
|
||||
RecordAuditEventInput,
|
||||
RecordAuditEventShortcutInput,
|
||||
RecordAuditInput,
|
||||
RecordAuditMutationInput,
|
||||
RecordAuditMutationShortcutInput,
|
||||
} from "./audit.types";
|
||||
export {
|
||||
AuditContextUnavailableError,
|
||||
AuditError,
|
||||
AuditStoreError,
|
||||
InvalidAuditInputError,
|
||||
InvalidAuditStateError,
|
||||
} from "./errors";
|
||||
89
packages/rdx-audit/src/internal/audit-diff.test.ts
Normal file
89
packages/rdx-audit/src/internal/audit-diff.test.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { AuditState } from "../audit-state.types";
|
||||
import { InvalidAuditInputError } from "../errors";
|
||||
import { diffAuditState } from "./audit-diff";
|
||||
import { validateAtomicPaths } from "./audit-input-validator";
|
||||
|
||||
function diff(before: AuditState, after: AuditState, paths: readonly string[] = []) {
|
||||
return diffAuditState(before, after, validateAtomicPaths(paths));
|
||||
}
|
||||
|
||||
test("returns no changes for equivalent values regardless of object key order", () => {
|
||||
assert.deepEqual(
|
||||
diff(
|
||||
{ profile: { id: "1", label: "A" }, tags: ["one", "two"] },
|
||||
{ tags: ["one", "two"], profile: { label: "A", id: "1" } }
|
||||
),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
test("creates scalar set, unset and change records", () => {
|
||||
assert.deepEqual(diff({}, { name: "Ada" }), [
|
||||
{ path: "name", kind: "set", before: null, after: "Ada" },
|
||||
]);
|
||||
assert.deepEqual(diff({ name: "Ada" }, {}), [
|
||||
{ path: "name", kind: "unset", before: "Ada", after: null },
|
||||
]);
|
||||
assert.deepEqual(diff({ name: "Ada" }, { name: "Grace" }), [
|
||||
{ path: "name", kind: "change", before: "Ada", after: "Grace" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("diffs nested objects recursively and sorts paths", () => {
|
||||
assert.deepEqual(
|
||||
diff(
|
||||
{ address: { city: "Madrid", street: "A" }, email: "old@example.com" },
|
||||
{ address: { city: "Barcelona", zip: "08001" }, email: "new@example.com" }
|
||||
),
|
||||
[
|
||||
{ path: "address.city", kind: "change", before: "Madrid", after: "Barcelona" },
|
||||
{ path: "address.street", kind: "unset", before: "A", after: null },
|
||||
{ path: "address.zip", kind: "set", before: null, after: "08001" },
|
||||
{ path: "email", kind: "change", before: "old@example.com", after: "new@example.com" },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test("treats arrays as atomic and distinguishes null from missing", () => {
|
||||
assert.deepEqual(diff({ tags: ["one"] }, { tags: ["two"] }), [
|
||||
{ path: "tags", kind: "change", before: ["one"], after: ["two"] },
|
||||
]);
|
||||
assert.deepEqual(diff({}, { value: null }), [
|
||||
{ path: "value", kind: "set", before: null, after: null },
|
||||
]);
|
||||
assert.deepEqual(diff({ value: null }, {}), [
|
||||
{ path: "value", kind: "unset", before: null, after: null },
|
||||
]);
|
||||
});
|
||||
|
||||
test("records incompatible structural changes at their current path", () => {
|
||||
assert.deepEqual(diff({ address: { city: "Madrid" }, value: "text" }, { address: "Madrid", value: { text: "x" } }), [
|
||||
{ path: "address", kind: "change", before: { city: "Madrid" }, after: "Madrid" },
|
||||
{ path: "value", kind: "change", before: "text", after: { text: "x" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("honours atomic paths including nested paths", () => {
|
||||
const before = { paymentTerm: { id: "PT1", label: "30 days" }, address: { city: "Madrid", zip: "1" } };
|
||||
const after = { paymentTerm: { id: "PT2", label: "60 days" }, address: { city: "Barcelona", zip: "1" } };
|
||||
|
||||
assert.equal(diff(before, after).length, 3);
|
||||
assert.deepEqual(diff(before, after, ["paymentTerm", "address.city"]), [
|
||||
{ path: "address.city", kind: "change", before: "Madrid", after: "Barcelona" },
|
||||
{
|
||||
path: "paymentTerm",
|
||||
kind: "change",
|
||||
before: { id: "PT1", label: "30 days" },
|
||||
after: { id: "PT2", label: "60 days" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects invalid or duplicate atomic paths", () => {
|
||||
assert.throws(() => validateAtomicPaths(["", "name"]), InvalidAuditInputError);
|
||||
assert.throws(() => validateAtomicPaths(["address..city"]), InvalidAuditInputError);
|
||||
assert.throws(() => validateAtomicPaths(["name", "name"]), InvalidAuditInputError);
|
||||
});
|
||||
95
packages/rdx-audit/src/internal/audit-diff.ts
Normal file
95
packages/rdx-audit/src/internal/audit-diff.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import type { AuditChange } from "../audit.types";
|
||||
import type { AuditObject, AuditState, AuditValue } from "../audit-state.types";
|
||||
|
||||
const MISSING = Symbol("missing-audit-value");
|
||||
|
||||
export function diffAuditState(
|
||||
before: AuditState,
|
||||
after: AuditState,
|
||||
atomicPaths: ReadonlySet<string>
|
||||
): readonly AuditChange[] {
|
||||
const changes: AuditChange[] = [];
|
||||
compareObjects(before, after, "", atomicPaths, changes);
|
||||
return changes.sort((left, right) => left.path.localeCompare(right.path));
|
||||
}
|
||||
|
||||
function compareObjects(
|
||||
before: AuditObject,
|
||||
after: AuditObject,
|
||||
prefix: string,
|
||||
atomicPaths: ReadonlySet<string>,
|
||||
changes: AuditChange[]
|
||||
): void {
|
||||
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
||||
for (const key of [...keys].sort((left, right) => left.localeCompare(right))) {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
const beforeValue = getOwnValue(before, key);
|
||||
const afterValue = getOwnValue(after, key);
|
||||
compareValues(beforeValue, afterValue, path, atomicPaths, changes);
|
||||
}
|
||||
}
|
||||
|
||||
function compareValues(
|
||||
before: AuditValue | typeof MISSING,
|
||||
after: AuditValue | typeof MISSING,
|
||||
path: string,
|
||||
atomicPaths: ReadonlySet<string>,
|
||||
changes: AuditChange[]
|
||||
): void {
|
||||
if (before === MISSING) {
|
||||
changes.push({ path, kind: "set", before: null, after: after as AuditValue });
|
||||
return;
|
||||
}
|
||||
if (after === MISSING) {
|
||||
changes.push({ path, kind: "unset", before, after: null });
|
||||
return;
|
||||
}
|
||||
if (deepEqual(before, after)) return;
|
||||
if (atomicPaths.has(path) || !isObject(before) || !isObject(after)) {
|
||||
changes.push({ path, kind: "change", before, after });
|
||||
return;
|
||||
}
|
||||
|
||||
compareObjects(before, after, path, atomicPaths, changes);
|
||||
}
|
||||
|
||||
function deepEqual(left: AuditValue, right: AuditValue): boolean {
|
||||
if (left === right) return true;
|
||||
if (typeof left !== "object" || left === null || typeof right !== "object" || right === null) {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(left) || Array.isArray(right)) {
|
||||
if (!(Array.isArray(left) && Array.isArray(right)) || left.length !== right.length) return false;
|
||||
return left.every((value, index) => {
|
||||
const rightValue = right[index];
|
||||
return rightValue !== undefined && deepEqual(value, rightValue);
|
||||
});
|
||||
}
|
||||
if (!(isObject(left) && isObject(right))) return false;
|
||||
|
||||
const leftKeys = Object.keys(left).sort();
|
||||
const rightKeys = Object.keys(right).sort();
|
||||
if (leftKeys.length !== rightKeys.length) return false;
|
||||
return leftKeys.every((key, index) => {
|
||||
const rightKey = rightKeys[index];
|
||||
const leftValue = left[key];
|
||||
const rightValue = right[key];
|
||||
return (
|
||||
rightKey !== undefined &&
|
||||
leftValue !== undefined &&
|
||||
rightValue !== undefined &&
|
||||
key === rightKey &&
|
||||
deepEqual(leftValue, rightValue)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getOwnValue(object: AuditObject, key: string): AuditValue | typeof MISSING {
|
||||
if (!Object.hasOwn(object, key)) return MISSING;
|
||||
const value = object[key];
|
||||
return value === undefined ? MISSING : value;
|
||||
}
|
||||
|
||||
function isObject(value: AuditValue): value is AuditObject {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
85
packages/rdx-audit/src/internal/audit-input-validator.ts
Normal file
85
packages/rdx-audit/src/internal/audit-input-validator.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import type {
|
||||
AuditContext,
|
||||
AuditEventTarget,
|
||||
AuditMutationTarget,
|
||||
AuditSource,
|
||||
RecordAuditInput,
|
||||
} from "../audit.types";
|
||||
import { InvalidAuditInputError } from "../errors";
|
||||
|
||||
const MAX_ACTION_LENGTH = 200;
|
||||
const MAX_IDENTIFIER_LENGTH = 255;
|
||||
const ACTION_PATTERN = /^[a-z0-9_-]+(?:\.[a-z0-9_-]+)+$/;
|
||||
|
||||
export function validateSource(source: AuditSource): void {
|
||||
validateIdentifier(source.type, "source.type");
|
||||
validateIdentifier(source.id, "source.id");
|
||||
}
|
||||
|
||||
export function validateRecordInput(input: RecordAuditInput): void {
|
||||
validateAction(input.action);
|
||||
if (input.kind === "event") {
|
||||
validateEventTarget(input.target);
|
||||
return;
|
||||
}
|
||||
validateMutationTarget(input.target);
|
||||
}
|
||||
|
||||
export function validateContext(context: AuditContext): void {
|
||||
validateIdentifier(context.operation.id, "operation.id");
|
||||
validateIdentifier(context.operation.correlationId, "operation.correlationId");
|
||||
validateIdentifier(context.operation.origin, "operation.origin");
|
||||
validateIdentifier(context.actor.type, "actor.type");
|
||||
validateOptionalIdentifier(context.actor.id, "actor.id");
|
||||
validateOptionalLabel(context.actor.label, "actor.label");
|
||||
|
||||
if (context.scope) {
|
||||
validateIdentifier(context.scope.type, "scope.type");
|
||||
validateIdentifier(context.scope.id, "scope.id");
|
||||
validateOptionalLabel(context.scope.label, "scope.label");
|
||||
}
|
||||
}
|
||||
|
||||
export function validateAtomicPaths(paths: readonly string[] | undefined): ReadonlySet<string> {
|
||||
const normalized = new Set<string>();
|
||||
for (const path of paths ?? []) {
|
||||
if (typeof path !== "string" || !path || path.split(".").some((segment) => !segment)) {
|
||||
throw new InvalidAuditInputError("atomicPaths must contain non-empty dot-separated paths.");
|
||||
}
|
||||
if (normalized.has(path)) throw new InvalidAuditInputError(`Duplicate atomic path "${path}".`);
|
||||
normalized.add(path);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateAction(action: string): void {
|
||||
if (typeof action !== "string" || action.length > MAX_ACTION_LENGTH || !ACTION_PATTERN.test(action)) {
|
||||
throw new InvalidAuditInputError("action must use lowercase dot-separated segments.");
|
||||
}
|
||||
}
|
||||
|
||||
function validateEventTarget(target: AuditEventTarget): void {
|
||||
validateIdentifier(target.type, "target.type");
|
||||
validateOptionalIdentifier(target.id, "target.id");
|
||||
validateOptionalLabel(target.label, "target.label");
|
||||
}
|
||||
|
||||
function validateMutationTarget(target: AuditMutationTarget): void {
|
||||
validateIdentifier(target.type, "target.type");
|
||||
validateIdentifier(target.id, "target.id");
|
||||
validateOptionalLabel(target.label, "target.label");
|
||||
}
|
||||
|
||||
function validateOptionalIdentifier(value: string | null, name: string): void {
|
||||
if (value !== null) validateIdentifier(value, name);
|
||||
}
|
||||
|
||||
function validateOptionalLabel(value: string | null, name: string): void {
|
||||
if (value !== null) validateIdentifier(value, name);
|
||||
}
|
||||
|
||||
function validateIdentifier(value: string, name: string): void {
|
||||
if (typeof value !== "string" || !value.trim() || value.length > MAX_IDENTIFIER_LENGTH) {
|
||||
throw new InvalidAuditInputError(`${name} must be a non-empty string up to ${MAX_IDENTIFIER_LENGTH} characters.`);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { InvalidAuditStateError } from "../errors";
|
||||
import { validateAuditState } from "./audit-state-validator";
|
||||
|
||||
test("accepts supported audit state values", () => {
|
||||
assert.doesNotThrow(() =>
|
||||
validateAuditState(
|
||||
{
|
||||
text: "value",
|
||||
enabled: true,
|
||||
empty: null,
|
||||
zero: 0,
|
||||
negative: -1.5,
|
||||
object: { nested: { label: "A" } },
|
||||
array: ["x", { y: false }],
|
||||
emptyObject: {},
|
||||
emptyArray: [],
|
||||
},
|
||||
"state"
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
for (const [name, value] of [
|
||||
["undefined", undefined],
|
||||
["NaN", Number.NaN],
|
||||
["Infinity", Number.POSITIVE_INFINITY],
|
||||
["-Infinity", Number.NEGATIVE_INFINITY],
|
||||
["Date", new Date()],
|
||||
["Buffer", Buffer.from("audit")],
|
||||
["bigint", 1n],
|
||||
["function", () => undefined],
|
||||
["class instance", new (class StateValue {})()],
|
||||
["Map", new Map()],
|
||||
["Set", new Set()],
|
||||
] as const) {
|
||||
test(`rejects ${name}`, () => {
|
||||
assert.throws(() => validateAuditState({ value: value as never }, "state"), InvalidAuditStateError);
|
||||
});
|
||||
}
|
||||
|
||||
test("rejects invalid nested values and keys", () => {
|
||||
assert.throws(
|
||||
() => validateAuditState({ nested: { value: new Date() as never } }, "state"),
|
||||
InvalidAuditStateError
|
||||
);
|
||||
assert.throws(
|
||||
() => validateAuditState({ "address.city": "Madrid" }, "state"),
|
||||
InvalidAuditStateError
|
||||
);
|
||||
assert.throws(() => validateAuditState({ "": "value" }, "state"), InvalidAuditStateError);
|
||||
});
|
||||
|
||||
test("rejects circular references", () => {
|
||||
const state: Record<string, unknown> = {};
|
||||
state.self = state;
|
||||
|
||||
assert.throws(() => validateAuditState(state as never, "state"), InvalidAuditStateError);
|
||||
});
|
||||
57
packages/rdx-audit/src/internal/audit-state-validator.ts
Normal file
57
packages/rdx-audit/src/internal/audit-state-validator.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { InvalidAuditStateError } from "../errors";
|
||||
import type { AuditState, AuditValue } from "../audit-state.types";
|
||||
|
||||
export function validateAuditState(state: AuditState, name: string): void {
|
||||
validateObject(state, name, new WeakSet<object>());
|
||||
}
|
||||
|
||||
function validateValue(value: unknown, path: string, ancestors: WeakSet<object>): asserts value is AuditValue {
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return;
|
||||
|
||||
if (typeof value === "number") {
|
||||
if (Number.isFinite(value)) return;
|
||||
throw new InvalidAuditStateError(`${path} must contain a finite number.`);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (ancestors.has(value)) throw new InvalidAuditStateError(`${path} contains a circular reference.`);
|
||||
ancestors.add(value);
|
||||
value.forEach((item, index) => {
|
||||
validateValue(item, `${path}[${index}]`, ancestors);
|
||||
});
|
||||
ancestors.delete(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
validateObject(value, path, ancestors);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidAuditStateError(`${path} contains an unsupported value.`);
|
||||
}
|
||||
|
||||
function validateObject(value: object, path: string, ancestors: WeakSet<object>): void {
|
||||
if (!isPlainObject(value)) throw new InvalidAuditStateError(`${path} must be a plain object.`);
|
||||
if (ancestors.has(value)) throw new InvalidAuditStateError(`${path} contains a circular reference.`);
|
||||
if (Object.getOwnPropertySymbols(value).length > 0) {
|
||||
throw new InvalidAuditStateError(`${path} must not contain symbol keys.`);
|
||||
}
|
||||
|
||||
ancestors.add(value);
|
||||
for (const [key, nestedValue] of Object.entries(value)) {
|
||||
if (!key) throw new InvalidAuditStateError(`${path} must not contain an empty key.`);
|
||||
if (key.includes(".")) throw new InvalidAuditStateError(`${path}.${key} must not contain '.'.`);
|
||||
validateValue(nestedValue, `${path}.${key}`, ancestors);
|
||||
}
|
||||
ancestors.delete(value);
|
||||
}
|
||||
|
||||
export function isPlainObject(value: unknown): value is Readonly<Record<string, unknown>> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value) || Buffer.isBuffer(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
9
packages/rdx-audit/tsconfig.json
Normal file
9
packages/rdx-audit/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/buildless.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
1936
pnpm-lock.yaml
1936
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user