Audit Wiring
Recipes are working wiring examples with the sharp edges annotated. This page and the
audit-wiringrecipe served to coding agents by the MCP server share one source (@modularityjs/manifest), so they never drift apart.
Two packages compose:
@modularityjs/audit—AuditServiceabstract contract +AuditModule. Methods:record(entry: NewAuditEntry) → AuditEntry(validates, stampsid+occurredAt, persists) andfind(query?: AuditQuery) → AuditEntry[](newest-first, criteria ANDed,limitdefault 100). An entry is{ actor?, action, subjectType?, subjectId?, context?, occurredAt? }—actionis a required dot-namespaced verb ('order.refunded');actorundefined means "system".@modularityjs/audit-memory(driver — bounded ring buffer for dev/tests,maxEntriesdefault 10 000) or@modularityjs/audit-database-{typeorm,prisma}(extensions — persistent stores over your database driver; TypeORM ships anaudit_logentity and registers it in the entities pool, Prisma maps a model, delegate nameauditLogby default).
Unlike metrics, audit write failures propagate — an entry that can't be persisted is a compliance event the caller must handle, not a droppable side channel. Auditing is an explicit call from the service performing the operation (there is no decorator or HTTP hook): that's where actor and subject are known precisely.
import { AuditModule, AuditService } from '@modularityjs/audit';
import { AuditMemoryModule } from '@modularityjs/audit-memory';
import { Inject, Injectable } from '@modularityjs/di';
import { inversify } from '@modularityjs/di-inversify';
import { createApp, Module, ModularityModule } from '@modularityjs/modularity';
@Injectable()
class OrderService {
constructor(@Inject(AuditService) private readonly audit: AuditService) {}
async refund(orderId: string, actorId: string): Promise<void> {
// ...perform the refund first; audit the completed fact...
await this.audit.record({
actor: actorId,
action: 'order.refunded',
subjectType: 'order',
subjectId: orderId,
context: { reason: 'customer-request' },
});
}
async history(orderId: string) {
return this.audit.find({ subjectType: 'order', subjectId: orderId });
}
}
@Module({
name: 'orders',
imports: [AuditModule],
providers: [OrderService],
})
class OrdersModule {}
const app = await createApp({
di: inversify,
modules: [ModularityModule, AuditModule, AuditMemoryModule, OrdersModule],
});Store switch is one line. Replace AuditMemoryModule with AuditDatabaseTypeormModule (next to DatabaseModule + DatabaseTypeormModule.forRoot({...})) or AuditDatabasePrismaModule — OrderService is unchanged. Memory is for dev/tests only; a compliance trail needs a database store.
Record identifiers, never payloads. Audit entries outlive operational data and context is stored verbatim — PII written there becomes a GDPR erasure problem inside a trail that must not be edited. subjectId: order.id, not the customer's address. Logger redaction does NOT apply — audit bypasses the logger entirely.
Name actions as <subject>.<verb-past-tense> (user.suspended, invoice.voided) and keep the vocabulary flat and greppable — find({ action }) is an exact match, not a prefix search.