From 489e0878e4bc3632fb6e334387a614db8487899a Mon Sep 17 00:00:00 2001 From: yasser Date: Wed, 25 Mar 2026 17:48:40 +0100 Subject: [PATCH 1/8] feat: add retention redaction idempotency and config validation --- src/core/audit.service.spec.ts | 98 ++++++++ src/core/audit.service.ts | 217 +++++++++++++++++- src/core/dtos/audit-log-response.dto.ts | 3 + src/core/dtos/create-audit-log.dto.ts | 3 + src/core/dtos/query-audit-logs.dto.ts | 6 + src/core/types.ts | 6 + .../in-memory/in-memory-audit.repository.ts | 33 ++- .../repositories/mongodb/audit-log.schema.ts | 5 +- .../mongodb/mongo-audit.repository.ts | 39 +++- src/nest/interfaces.ts | 64 ++++++ src/nest/module.spec.ts | 43 +++- src/nest/module.ts | 30 ++- src/nest/options.validation.ts | 114 +++++++++ src/nest/providers.ts | 35 ++- tsconfig.json | 3 +- 15 files changed, 666 insertions(+), 33 deletions(-) create mode 100644 src/nest/options.validation.ts diff --git a/src/core/audit.service.spec.ts b/src/core/audit.service.spec.ts index 297ce9e..c80538a 100644 --- a/src/core/audit.service.spec.ts +++ b/src/core/audit.service.spec.ts @@ -384,6 +384,104 @@ describe("AuditService", () => { expect(result.success).toBe(true); } }); + + it("should redact configured PII fields before persistence", async () => { + const redactingService = new AuditService( + mockRepository, + mockIdGenerator, + mockTimestampProvider, + mockChangeDetector, + { + piiRedaction: { + enabled: true, + fields: ["actor.email", "metadata.secret", "ipAddress"], + mask: "***", + }, + }, + ); + + mockRepository.create.mockResolvedValue(expectedAuditLog); + + await redactingService.log({ + ...validDto, + actor: { + ...validActor, + email: "john@example.com", + }, + metadata: { secret: "top-secret" }, + ipAddress: MOCK_IP_ADDRESS_2, + }); + + expect(mockRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + actor: expect.objectContaining({ email: "***" }), + metadata: expect.objectContaining({ secret: "***" }), + ipAddress: "***", + }), + ); + }); + + it("should deduplicate writes when idempotency key already exists", async () => { + const idempotentService = new AuditService( + mockRepository, + mockIdGenerator, + mockTimestampProvider, + mockChangeDetector, + { + idempotency: { + enabled: true, + keyStrategy: "idempotencyKey", + }, + }, + ); + + mockRepository.query.mockResolvedValue({ + data: [expectedAuditLog], + page: 1, + limit: 1, + total: 1, + pages: 1, + }); + + const result = await idempotentService.log({ + ...validDto, + idempotencyKey: "idem-1", + }); + + expect(result.success).toBe(true); + expect(result.data).toEqual(expectedAuditLog); + expect(result.metadata?.idempotentHit).toBe(true); + expect(mockRepository.create).not.toHaveBeenCalled(); + }); + + it("should run retention after write when enabled", async () => { + const retentionService = new AuditService( + mockRepository, + mockIdGenerator, + mockTimestampProvider, + mockChangeDetector, + { + retention: { + enabled: true, + retentionDays: 30, + autoCleanupOnWrite: true, + archiveBeforeDelete: true, + }, + }, + ); + + mockRepository.create.mockResolvedValue(expectedAuditLog); + (mockRepository.archiveOlderThan as jest.Mock).mockResolvedValue(2); + (mockRepository.deleteOlderThan as jest.Mock).mockResolvedValue(3); + + const result = await retentionService.log(validDto); + + expect(mockRepository.archiveOlderThan).toHaveBeenCalled(); + expect(mockRepository.deleteOlderThan).toHaveBeenCalled(); + expect(result.metadata?.retention).toBeDefined(); + expect(result.metadata?.retention?.archived).toBe(2); + expect(result.metadata?.retention?.deleted).toBe(3); + }); }); // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/src/core/audit.service.ts b/src/core/audit.service.ts index 94eac8c..0ab013c 100644 --- a/src/core/audit.service.ts +++ b/src/core/audit.service.ts @@ -78,6 +78,41 @@ export interface CreateAuditLogResult { * Number of fields changed (if applicable) */ fieldCount?: number; + + /** + * Whether the operation reused an existing log due to idempotency. + */ + idempotentHit?: boolean; + + /** + * Retention processing summary. + */ + retention?: { + archived: number; + deleted: number; + cutoffDate: Date; + }; + }; +} + +/** + * Runtime options for advanced audit service behavior. + */ +export interface AuditServiceOptions { + piiRedaction?: { + enabled?: boolean; + fields?: string[]; + mask?: string; + }; + idempotency?: { + enabled?: boolean; + keyStrategy?: "idempotencyKey" | "requestId"; + }; + retention?: { + enabled?: boolean; + retentionDays?: number; + autoCleanupOnWrite?: boolean; + archiveBeforeDelete?: boolean; }; } @@ -124,6 +159,8 @@ export class AuditService { private readonly _timestampProvider: ITimestampProvider, // eslint-disable-next-line no-unused-vars private readonly _changeDetector?: IChangeDetector, + // eslint-disable-next-line no-unused-vars + private readonly _options?: AuditServiceOptions, ) {} // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -170,6 +207,20 @@ export class AuditService { // Cast to Actor because CreateAuditLogDto's actor has optional fields but satisfies the interface this.validateActor(dto.actor as AuditLog["actor"]); + // Check idempotency before creating a new entry. + const existing = await this.findExistingByIdempotency(dto); + if (existing) { + return { + success: true, + data: existing, + metadata: { + duration: Date.now() - startTime, + fieldCount: dto.changes ? Object.keys(dto.changes).length : 0, + idempotentHit: true, + }, + }; + } + // Generate a unique ID for this audit log entry const id = this._idGenerator.generate({ prefix: "audit_" }); @@ -204,24 +255,38 @@ export class AuditService { if (dto.sessionId !== undefined) { (auditLog as any).sessionId = dto.sessionId; } + if (dto.idempotencyKey !== undefined) { + (auditLog as any).idempotencyKey = dto.idempotencyKey; + } if (dto.reason !== undefined) { (auditLog as any).reason = dto.reason; } + const logToPersist = this.applyPiiRedaction(auditLog); + // Persist the audit log to the repository - const created = await this._repository.create(auditLog); + const created = await this._repository.create(logToPersist); + + const retentionResult = await this.runRetentionIfEnabled(timestamp); // Calculate operation duration const duration = Date.now() - startTime; // Return success result with metadata + const metadata: NonNullable = { + duration, + fieldCount: dto.changes ? Object.keys(dto.changes).length : 0, + idempotentHit: false, + }; + + if (retentionResult) { + metadata.retention = retentionResult; + } + return { success: true, data: created, - metadata: { - duration, - fieldCount: dto.changes ? Object.keys(dto.changes).length : 0, - }, + metadata, }; } catch (error) { // Return failure result with error details @@ -488,6 +553,7 @@ export class AuditService { if (dto.endDate !== undefined) filters.endDate = dto.endDate; if (dto.ipAddress !== undefined) filters.ipAddress = dto.ipAddress; if (dto.search !== undefined) filters.search = dto.search; + if (dto.idempotencyKey !== undefined) filters.idempotencyKey = dto.idempotencyKey; if (dto.page !== undefined) filters.page = dto.page; if (dto.limit !== undefined) filters.limit = dto.limit; if (dto.sort !== undefined) filters.sort = dto.sort; @@ -580,4 +646,145 @@ export class AuditService { throw InvalidActorError.invalidType(actor.type); } } + + /** + * Finds an existing audit log when idempotency is enabled and a key is present. + */ + private async findExistingByIdempotency(dto: CreateAuditLogDto): Promise { + if (!this._options?.idempotency?.enabled) { + return null; + } + + const strategy = this._options.idempotency.keyStrategy ?? "idempotencyKey"; + const key = strategy === "requestId" ? dto.requestId : dto.idempotencyKey; + if (!key) { + return null; + } + + const queryFilters: Partial & Partial = { + page: 1, + limit: 1, + sort: "-timestamp", + }; + + if (strategy === "idempotencyKey") { + queryFilters.idempotencyKey = key; + } else { + queryFilters.requestId = key; + } + + const existing = await this._repository.query(queryFilters); + + return existing.data[0] ?? null; + } + + /** + * Applies configured PII redaction to selected fields before persistence. + */ + private applyPiiRedaction(log: AuditLog): AuditLog { + if (!this._options?.piiRedaction?.enabled) { + return log; + } + + const redactionPaths = + this._options.piiRedaction.fields && this._options.piiRedaction.fields.length > 0 + ? this._options.piiRedaction.fields + : ["actor.email", "metadata.password", "metadata.token", "metadata.authorization"]; + const mask = this._options.piiRedaction.mask ?? "[REDACTED]"; + + const cloned = this.deepClone(log); + for (const path of redactionPaths) { + this.redactPath(cloned as unknown as Record, path, mask); + } + + return cloned; + } + + /** + * Applies retention policy after writes when configured. + */ + private async runRetentionIfEnabled( + timestamp: Date, + ): Promise<{ archived: number; deleted: number; cutoffDate: Date } | undefined> { + if (!this._options?.retention?.enabled || !this._options.retention.autoCleanupOnWrite) { + return undefined; + } + + const retentionDays = this._options.retention.retentionDays; + if (!retentionDays || retentionDays <= 0) { + return undefined; + } + + const cutoffDate = new Date(timestamp); + cutoffDate.setDate(cutoffDate.getDate() - retentionDays); + + let archived = 0; + if (this._options.retention.archiveBeforeDelete && this._repository.archiveOlderThan) { + archived = await this._repository.archiveOlderThan(cutoffDate); + } + + let deleted = 0; + if (this._repository.deleteOlderThan) { + deleted = await this._repository.deleteOlderThan(cutoffDate); + } + + return { archived, deleted, cutoffDate }; + } + + /** + * Redacts one dot-path inside a mutable object. + */ + private redactPath(target: Record, path: string, mask: string): void { + const segments = path.split(".").filter(Boolean); + if (segments.length === 0) { + return; + } + + let cursor: Record = target; + for (let i = 0; i < segments.length - 1; i += 1) { + const segment = segments[i]; + if (!segment) { + return; + } + + const next = cursor[segment]; + if (!next || typeof next !== "object" || Array.isArray(next)) { + return; + } + cursor = next as Record; + } + + const leaf = segments[segments.length - 1]; + if (!leaf) { + return; + } + + if (leaf in cursor) { + cursor[leaf] = mask; + } + } + + /** + * Deep clones plain objects while preserving Date instances. + */ + private deepClone(value: T): T { + if (value instanceof Date) { + return new Date(value.getTime()) as T; + } + + if (Array.isArray(value)) { + return value.map((item) => this.deepClone(item)) as T; + } + + if (value && typeof value === "object") { + const source = value as Record; + const cloned: Record = {}; + for (const [key, item] of Object.entries(source)) { + cloned[key] = this.deepClone(item); + } + return cloned as T; + } + + return value; + } } diff --git a/src/core/dtos/audit-log-response.dto.ts b/src/core/dtos/audit-log-response.dto.ts index 67c6ece..7c97ba4 100644 --- a/src/core/dtos/audit-log-response.dto.ts +++ b/src/core/dtos/audit-log-response.dto.ts @@ -129,6 +129,9 @@ export const AuditLogResponseDtoSchema = z.object({ /** Session ID */ sessionId: z.string().optional(), + /** Idempotency key */ + idempotencyKey: z.string().optional(), + // ───────────────────────────────────────────────────────────────────────── // COMPLIANCE // ───────────────────────────────────────────────────────────────────────── diff --git a/src/core/dtos/create-audit-log.dto.ts b/src/core/dtos/create-audit-log.dto.ts index ebe6f02..349cf6d 100644 --- a/src/core/dtos/create-audit-log.dto.ts +++ b/src/core/dtos/create-audit-log.dto.ts @@ -180,6 +180,9 @@ export const CreateAuditLogDtoSchema = z.object({ /** Session ID (if applicable) */ sessionId: z.string().optional(), + /** Idempotency key used to deduplicate retried writes */ + idempotencyKey: z.string().min(1, "Idempotency key cannot be empty").max(128).optional(), + /** * Human-readable reason or justification. * May be required by compliance policies for sensitive operations. diff --git a/src/core/dtos/query-audit-logs.dto.ts b/src/core/dtos/query-audit-logs.dto.ts index e71ad37..5779b28 100644 --- a/src/core/dtos/query-audit-logs.dto.ts +++ b/src/core/dtos/query-audit-logs.dto.ts @@ -228,6 +228,12 @@ export const QueryAuditLogsDtoSchema = z.object({ */ sessionId: z.string().optional(), + /** + * Filter by idempotency key. + * Example: Find the deduplicated audit write for a retried request + */ + idempotencyKey: z.string().optional(), + // ───────────────────────────────────────────────────────────────────────── // FULL-TEXT SEARCH // ───────────────────────────────────────────────────────────────────────── diff --git a/src/core/types.ts b/src/core/types.ts index ba8118c..7c1a676 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -248,6 +248,9 @@ export interface AuditLog { /** Session ID (if applicable) */ sessionId?: string; + /** Idempotency key for deduplicating repeated writes */ + idempotencyKey?: string; + // ───────────────────────────────────────────────────────────────────────── // COMPLIANCE - Justification // ───────────────────────────────────────────────────────────────────────── @@ -340,6 +343,9 @@ export interface AuditLogFilters { /** Filter by session ID */ sessionId?: string; + /** Filter by idempotency key */ + idempotencyKey?: string; + /** Free-text search across multiple fields */ search?: string; diff --git a/src/infra/repositories/in-memory/in-memory-audit.repository.ts b/src/infra/repositories/in-memory/in-memory-audit.repository.ts index 22edba0..7736308 100644 --- a/src/infra/repositories/in-memory/in-memory-audit.repository.ts +++ b/src/infra/repositories/in-memory/in-memory-audit.repository.ts @@ -34,6 +34,9 @@ import type { IAuditLogRepository } from "../../../core/ports/audit-repository.port"; import type { AuditLog, AuditLogFilters, PageOptions, PageResult } from "../../../core/types"; +// eslint-disable-next-line no-unused-vars +type ArchiveHandler = (logs: AuditLog[]) => Promise | void; + /** * In-memory implementation of audit log repository. * @@ -74,12 +77,15 @@ export class InMemoryAuditRepository implements IAuditLogRepository { */ private readonly logs = new Map(); + private readonly archiveHandler: ArchiveHandler | undefined; + /** * Creates a new in-memory repository. * * @param initialData - Optional initial audit logs (for testing) */ - constructor(initialData?: AuditLog[]) { + constructor(initialData?: AuditLog[], archiveHandler?: ArchiveHandler) { + this.archiveHandler = archiveHandler; if (initialData) { initialData.forEach((log) => this.logs.set(log.id, log)); } @@ -258,6 +264,30 @@ export class InMemoryAuditRepository implements IAuditLogRepository { return deleted; } + /** + * Archives audit logs older than the specified date. + * + * If no archive handler is configured, this is a no-op. + * + * @param beforeDate - Archive logs older than this date + * @returns Number of archived logs + */ + async archiveOlderThan(beforeDate: Date): Promise { + if (!this.archiveHandler) { + return 0; + } + + const logsToArchive = Array.from(this.logs.values()).filter( + (log) => log.timestamp < beforeDate, + ); + if (logsToArchive.length === 0) { + return 0; + } + + await this.archiveHandler(logsToArchive.map((log) => this.deepCopy(log))); + return logsToArchive.length; + } + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // UTILITY METHODS (Testing Support) // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -318,6 +348,7 @@ export class InMemoryAuditRepository implements IAuditLogRepository { if (filters.ipAddress && log.ipAddress !== filters.ipAddress) return false; if (filters.requestId && log.requestId !== filters.requestId) return false; if (filters.sessionId && log.sessionId !== filters.sessionId) return false; + if (filters.idempotencyKey && log.idempotencyKey !== filters.idempotencyKey) return false; // Simple text search (searches in action, resource type, actor name) if (filters.search) { diff --git a/src/infra/repositories/mongodb/audit-log.schema.ts b/src/infra/repositories/mongodb/audit-log.schema.ts index 54f8ad9..502e7df 100644 --- a/src/infra/repositories/mongodb/audit-log.schema.ts +++ b/src/infra/repositories/mongodb/audit-log.schema.ts @@ -126,12 +126,15 @@ export const AuditLogSchema = new Schema( }, requestId: { type: String, - index: true, }, sessionId: { type: String, index: true, }, + idempotencyKey: { + type: String, + sparse: true, + }, reason: { type: String, }, diff --git a/src/infra/repositories/mongodb/mongo-audit.repository.ts b/src/infra/repositories/mongodb/mongo-audit.repository.ts index ff0b8ba..c16c0aa 100644 --- a/src/infra/repositories/mongodb/mongo-audit.repository.ts +++ b/src/infra/repositories/mongodb/mongo-audit.repository.ts @@ -26,6 +26,9 @@ import type { AuditLog, AuditLogFilters, PageOptions, PageResult } from "../../. import type { AuditLogDocument } from "./audit-log.schema"; +// eslint-disable-next-line no-unused-vars +type ArchiveHandler = (logs: AuditLog[]) => Promise | void; + /** * MongoDB implementation of audit log repository. * @@ -52,13 +55,18 @@ import type { AuditLogDocument } from "./audit-log.schema"; * ``` */ export class MongoAuditRepository implements IAuditLogRepository { + private readonly model: Model; + private readonly archiveHandler: ArchiveHandler | undefined; + /** * Creates a new MongoDB audit repository. * * @param model - Mongoose model for AuditLog */ - // eslint-disable-next-line no-unused-vars - constructor(private readonly model: Model) {} + constructor(model: Model, archiveHandler?: ArchiveHandler) { + this.model = model; + this.archiveHandler = archiveHandler; + } // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // CREATE OPERATIONS @@ -209,6 +217,32 @@ export class MongoAuditRepository implements IAuditLogRepository { return result.deletedCount || 0; } + /** + * Archives audit logs older than the specified date. + * + * If no archive handler is configured, this is a no-op. + * + * @param beforeDate - Archive logs older than this date + * @returns Number of archived logs + */ + async archiveOlderThan(beforeDate: Date): Promise { + if (!this.archiveHandler) { + return 0; + } + + const documents = await this.model + .find({ timestamp: { $lt: beforeDate } }) + .lean() + .exec(); + if (documents.length === 0) { + return 0; + } + + const logs = documents.map((doc) => this.toPlainObject(doc)); + await this.archiveHandler(logs); + return logs.length; + } + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // PRIVATE HELPER METHODS // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -251,6 +285,7 @@ export class MongoAuditRepository implements IAuditLogRepository { if (filters.ipAddress) query.ipAddress = filters.ipAddress; if (filters.requestId) query.requestId = filters.requestId; if (filters.sessionId) query.sessionId = filters.sessionId; + if (filters.idempotencyKey) query.idempotencyKey = filters.idempotencyKey; // Full-text search (if text index is configured) if (filters.search) { diff --git a/src/nest/interfaces.ts b/src/nest/interfaces.ts index a37dcc5..d97c772 100644 --- a/src/nest/interfaces.ts +++ b/src/nest/interfaces.ts @@ -15,8 +15,12 @@ import type { ModuleMetadata, Type } from "@nestjs/common"; import type { Model } from "mongoose"; +import type { AuditLog } from "../core/types"; import type { AuditLogDocument } from "../infra/repositories/mongodb/audit-log.schema"; +// eslint-disable-next-line no-unused-vars +export type ArchiveHandler = (logs: AuditLog[]) => Promise | void; + // ============================================================================ // REPOSITORY CONFIGURATION // ============================================================================ @@ -132,6 +136,51 @@ export interface ChangeDetectorConfig { type?: "deep-diff"; } +/** + * PII redaction configuration. + */ +export interface RedactionConfig { + /** Enable/disable redaction before persistence. */ + enabled?: boolean; + + /** Dot-path fields to redact (e.g. actor.email, metadata.token). */ + fields?: string[]; + + /** Replacement mask value. */ + mask?: string; +} + +/** + * Idempotency configuration. + */ +export interface IdempotencyConfig { + /** Enable/disable write deduplication. */ + enabled?: boolean; + + /** Which field is used as dedupe key. */ + keyStrategy?: "idempotencyKey" | "requestId"; +} + +/** + * Retention and archival configuration. + */ +export interface RetentionConfig { + /** Enable retention processing. */ + enabled?: boolean; + + /** Number of days to retain hot data. */ + retentionDays?: number; + + /** Whether to run retention cleanup after each write. */ + autoCleanupOnWrite?: boolean; + + /** Archive logs before delete operations. */ + archiveBeforeDelete?: boolean; + + /** Optional callback receiving logs selected for archival. */ + archiveHandler?: ArchiveHandler; +} + // ============================================================================ // MAIN MODULE OPTIONS // ============================================================================ @@ -204,6 +253,21 @@ export interface AuditKitModuleOptions { * Optional - defaults to deep-diff detector. */ changeDetector?: ChangeDetectorConfig; + + /** + * PII redaction policy applied before persistence. + */ + redaction?: RedactionConfig; + + /** + * Idempotency policy to deduplicate repeated writes. + */ + idempotency?: IdempotencyConfig; + + /** + * Retention and archival policy. + */ + retention?: RetentionConfig; } // ============================================================================ diff --git a/src/nest/module.spec.ts b/src/nest/module.spec.ts index 8eb3029..76e7481 100644 --- a/src/nest/module.spec.ts +++ b/src/nest/module.spec.ts @@ -475,17 +475,38 @@ describe("AuditKitModule", () => { }); it("should throw for mongodb config without uri or model", async () => { - await expect( - Test.createTestingModule({ - imports: [ - AuditKitModule.register({ - repository: { - type: "mongodb", - }, - }), - ], - }).compile(), - ).rejects.toThrow("MongoDB repository requires either 'uri' or 'model' to be configured"); + expect(() => + AuditKitModule.register({ + repository: { + type: "mongodb", + }, + }), + ).toThrow("MongoDB repository requires either 'uri' or 'model' to be configured"); + }); + + it("should throw for invalid retention days", async () => { + expect(() => + AuditKitModule.register({ + repository: { type: "in-memory" }, + retention: { + enabled: true, + retentionDays: 0, + }, + }), + ).toThrow("Retention requires a positive integer 'retentionDays'"); + }); + + it("should throw when archive-before-delete has no handler", async () => { + expect(() => + AuditKitModule.register({ + repository: { type: "in-memory" }, + retention: { + enabled: true, + retentionDays: 30, + archiveBeforeDelete: true, + }, + }), + ).toThrow("Retention with archiveBeforeDelete=true requires an archiveHandler"); }); }); }); diff --git a/src/nest/module.ts b/src/nest/module.ts index 755a579..a000afb 100644 --- a/src/nest/module.ts +++ b/src/nest/module.ts @@ -42,6 +42,11 @@ import { TIMESTAMP_PROVIDER, } from "./constants"; import type { AuditKitModuleAsyncOptions, AuditKitModuleOptions } from "./interfaces"; +import { + getArchiveHandler, + toAuditServiceRuntimeOptions, + validateAuditKitModuleOptions, +} from "./options.validation"; import { createAuditKitAsyncProviders, createAuditKitProviders } from "./providers"; // ============================================================================ @@ -164,6 +169,7 @@ export class AuditKitModule { * ``` */ static register(options: AuditKitModuleOptions): DynamicModule { + validateAuditKitModuleOptions(options); const providers = createAuditKitProviders(options); return { @@ -320,13 +326,14 @@ export class AuditKitModule { useFactory: async ( moduleOptions: AuditKitModuleOptions, ): Promise => { + validateAuditKitModuleOptions(moduleOptions); const config = moduleOptions.repository; switch (config.type) { case "mongodb": { // If a model is provided, use it directly if (config.model) { - return new MongoAuditRepository(config.model); + return new MongoAuditRepository(config.model, getArchiveHandler(moduleOptions)); } // Otherwise, create a connection and model @@ -343,12 +350,12 @@ export class AuditKitModule { const connection = await connect(config.uri, connectionOptions as ConnectOptions); const model = connection.model("AuditLog", AuditLogSchema); - return new MongoAuditRepository(model); + return new MongoAuditRepository(model, getArchiveHandler(moduleOptions)); } case "in-memory": default: - return new InMemoryAuditRepository(); + return new InMemoryAuditRepository(undefined, getArchiveHandler(moduleOptions)); } }, inject: [AUDIT_KIT_OPTIONS], @@ -361,10 +368,23 @@ export class AuditKitModule { idGenerator: IIdGenerator, timestampProvider: ITimestampProvider, changeDetector: IChangeDetector, + moduleOptions: AuditKitModuleOptions, ) => { - return new AuditService(repository, idGenerator, timestampProvider, changeDetector); + return new AuditService( + repository, + idGenerator, + timestampProvider, + changeDetector, + toAuditServiceRuntimeOptions(moduleOptions), + ); }, - inject: [AUDIT_REPOSITORY, ID_GENERATOR, TIMESTAMP_PROVIDER, CHANGE_DETECTOR], + inject: [ + AUDIT_REPOSITORY, + ID_GENERATOR, + TIMESTAMP_PROVIDER, + CHANGE_DETECTOR, + AUDIT_KIT_OPTIONS, + ], }, ], exports: [AuditService, AUDIT_REPOSITORY, ID_GENERATOR, TIMESTAMP_PROVIDER, CHANGE_DETECTOR], diff --git a/src/nest/options.validation.ts b/src/nest/options.validation.ts new file mode 100644 index 0000000..9224dfb --- /dev/null +++ b/src/nest/options.validation.ts @@ -0,0 +1,114 @@ +/** + * ============================================================================ + * AUDIT KIT MODULE OPTIONS VALIDATION + * ============================================================================ + * + * Centralized runtime validation for module options. + * + * @packageDocumentation + */ + +import type { AuditKitModuleOptions } from "./interfaces"; + +/** + * Runtime options passed to AuditService from module configuration. + */ +export interface AuditServiceRuntimeOptions { + piiRedaction?: { + enabled?: boolean; + fields?: string[]; + mask?: string; + }; + idempotency?: { + enabled?: boolean; + keyStrategy?: "idempotencyKey" | "requestId"; + }; + retention?: { + enabled?: boolean; + retentionDays?: number; + autoCleanupOnWrite?: boolean; + archiveBeforeDelete?: boolean; + }; +} + +/** + * Validates module options and throws a descriptive Error on invalid configuration. + */ +export function validateAuditKitModuleOptions(options: AuditKitModuleOptions): void { + if (!options || !options.repository) { + throw new Error("AuditKitModule options must include a repository configuration"); + } + + if (options.repository.type === "mongodb") { + if (!options.repository.uri && !options.repository.model) { + throw new Error("MongoDB repository requires either 'uri' or 'model' to be configured"); + } + } + + if ( + options.redaction?.fields && + options.redaction.fields.some((field) => typeof field !== "string" || field.trim().length === 0) + ) { + throw new Error("Redaction fields must be non-empty strings"); + } + + if (options.retention?.enabled) { + const retentionDays = options.retention.retentionDays; + if (!Number.isInteger(retentionDays) || (retentionDays as number) <= 0) { + throw new Error("Retention requires a positive integer 'retentionDays'"); + } + + if (options.retention.archiveBeforeDelete && options.retention.archiveHandler === undefined) { + throw new Error("Retention with archiveBeforeDelete=true requires an archiveHandler"); + } + } + + if (options.idempotency?.keyStrategy === "requestId" && options.idempotency.enabled === false) { + throw new Error("Idempotency key strategy is configured but idempotency is disabled"); + } +} + +/** + * Maps module options to runtime options consumed by AuditService. + */ +export function toAuditServiceRuntimeOptions( + options: AuditKitModuleOptions, +): AuditServiceRuntimeOptions { + const runtimeOptions: AuditServiceRuntimeOptions = {}; + + if (options.redaction) { + runtimeOptions.piiRedaction = options.redaction; + } + + if (options.idempotency) { + runtimeOptions.idempotency = options.idempotency; + } + + if (options.retention) { + const retention: NonNullable = {}; + if (options.retention.enabled !== undefined) retention.enabled = options.retention.enabled; + if (options.retention.retentionDays !== undefined) { + retention.retentionDays = options.retention.retentionDays; + } + if (options.retention.autoCleanupOnWrite !== undefined) { + retention.autoCleanupOnWrite = options.retention.autoCleanupOnWrite; + } + if (options.retention.archiveBeforeDelete !== undefined) { + retention.archiveBeforeDelete = options.retention.archiveBeforeDelete; + } + runtimeOptions.retention = retention; + } + + return runtimeOptions; +} + +/** + * Extracts archive handler function from module options. + */ +export function getArchiveHandler( + options: AuditKitModuleOptions, +): AuditKitModuleOptions["retention"] extends undefined + ? undefined + : NonNullable["archiveHandler"] { + return options.retention?.archiveHandler; +} diff --git a/src/nest/providers.ts b/src/nest/providers.ts index 188c0ad..3ec0249 100644 --- a/src/nest/providers.ts +++ b/src/nest/providers.ts @@ -37,6 +37,11 @@ import { TIMESTAMP_PROVIDER, } from "./constants"; import type { AuditKitModuleOptions } from "./interfaces"; +import { + getArchiveHandler, + toAuditServiceRuntimeOptions, + validateAuditKitModuleOptions, +} from "./options.validation"; // ============================================================================ // PROVIDER FACTORY @@ -59,6 +64,8 @@ import type { AuditKitModuleOptions } from "./interfaces"; * @internal */ export function createAuditKitProviders(options: AuditKitModuleOptions): Provider[] { + validateAuditKitModuleOptions(options); + return [ // Configuration provider { @@ -144,7 +151,7 @@ export function createAuditKitProviders(options: AuditKitModuleOptions): Provide case "mongodb": { // If a model is provided, use it directly if (config.model) { - return new MongoAuditRepository(config.model); + return new MongoAuditRepository(config.model, getArchiveHandler(options)); } // Otherwise, create a connection and model @@ -162,12 +169,12 @@ export function createAuditKitProviders(options: AuditKitModuleOptions): Provide const connection = await connect(config.uri, connectionOptions as ConnectOptions); const model = connection.model("AuditLog", AuditLogSchema); - return new MongoAuditRepository(model); + return new MongoAuditRepository(model, getArchiveHandler(options)); } case "in-memory": default: - return new InMemoryAuditRepository(); + return new InMemoryAuditRepository(undefined, getArchiveHandler(options)); } }, }, @@ -181,7 +188,13 @@ export function createAuditKitProviders(options: AuditKitModuleOptions): Provide timestampProvider: ITimestampProvider, changeDetector: IChangeDetector, ) => { - return new AuditService(repository, idGenerator, timestampProvider, changeDetector); + return new AuditService( + repository, + idGenerator, + timestampProvider, + changeDetector, + toAuditServiceRuntimeOptions(options), + ); }, inject: [AUDIT_REPOSITORY, ID_GENERATOR, TIMESTAMP_PROVIDER, CHANGE_DETECTOR], }, @@ -211,7 +224,11 @@ export function createAuditKitAsyncProviders(options: { return [ { provide: AUDIT_KIT_OPTIONS, - useFactory: options.useFactory, + useFactory: async (...args: any[]) => { + const resolved = await options.useFactory!(...args); + validateAuditKitModuleOptions(resolved); + return resolved; + }, inject: options.inject ?? [], }, ]; @@ -224,7 +241,9 @@ export function createAuditKitAsyncProviders(options: { useFactory: async (optionsFactory: { createAuditKitOptions: () => Promise | AuditKitModuleOptions; }) => { - return await optionsFactory.createAuditKitOptions(); + const resolved = await optionsFactory.createAuditKitOptions(); + validateAuditKitModuleOptions(resolved); + return resolved; }, inject: [options.useClass], }, @@ -242,7 +261,9 @@ export function createAuditKitAsyncProviders(options: { useFactory: async (optionsFactory: { createAuditKitOptions: () => Promise | AuditKitModuleOptions; }) => { - return await optionsFactory.createAuditKitOptions(); + const resolved = await optionsFactory.createAuditKitOptions(); + validateAuditKitModuleOptions(resolved); + return resolved; }, inject: [options.useExisting], }, diff --git a/tsconfig.json b/tsconfig.json index 63ab110..8491088 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,8 @@ "resolveJsonModule": true, "skipLibCheck": true, "types": ["jest"], - "baseUrl": "." + "baseUrl": ".", + "ignoreDeprecations": "6.0" }, "include": ["src/**/*.ts", "test/**/*.ts"], "exclude": ["dist", "node_modules"] From 0b5290421cc0ef9fd67b612d6744037881b35848 Mon Sep 17 00:00:00 2001 From: yasser Date: Wed, 25 Mar 2026 17:53:38 +0100 Subject: [PATCH 2/8] fix: use compatible ignoreDeprecations value --- tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 8491088..e1060c9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,7 @@ "skipLibCheck": true, "types": ["jest"], "baseUrl": ".", - "ignoreDeprecations": "6.0" + "ignoreDeprecations": "5.0" }, "include": ["src/**/*.ts", "test/**/*.ts"], "exclude": ["dist", "node_modules"] From e9ce19328d6019bac5cc9c5cba8b8bdf1f7a3703 Mon Sep 17 00:00:00 2001 From: yasser Date: Thu, 26 Mar 2026 14:06:19 +0100 Subject: [PATCH 3/8] feat: add cursor pagination, OTel observer hooks, mutation testing, and benchmarks --- benchmarks/audit-service.bench.ts | 255 ++ package-lock.json | 2647 +++++++++++++++-- package.json | 7 +- src/core/audit.service.spec.ts | 146 +- src/core/audit.service.ts | 114 +- src/core/index.ts | 13 +- src/core/ports/audit-observer.port.ts | 136 + src/core/ports/audit-repository.port.ts | 44 +- src/core/ports/index.ts | 10 + src/core/types.ts | 41 + src/infra/repositories/cursor.util.ts | 64 + .../in-memory/in-memory-audit.repository.ts | 64 +- .../mongodb/mongo-audit.repository.ts | 67 +- src/nest/interfaces.ts | 20 + src/nest/options.validation.ts | 8 + stryker.config.json | 31 + tsconfig.eslint.json | 9 +- tsconfig.json | 4 +- vitest.config.ts | 4 + 19 files changed, 3505 insertions(+), 179 deletions(-) create mode 100644 benchmarks/audit-service.bench.ts create mode 100644 src/core/ports/audit-observer.port.ts create mode 100644 src/infra/repositories/cursor.util.ts create mode 100644 stryker.config.json diff --git a/benchmarks/audit-service.bench.ts b/benchmarks/audit-service.bench.ts new file mode 100644 index 0000000..a90df5b --- /dev/null +++ b/benchmarks/audit-service.bench.ts @@ -0,0 +1,255 @@ +/** + * ============================================================================ + * AUDIT SERVICE PERFORMANCE BENCHMARKS + * ============================================================================ + * + * Measures throughput and latency of core AuditService operations using + * Vitest's built-in bench runner (backed by tinybench). + * + * Run with: npm run bench + * + * Benchmarks cover: + * - log() — single audit log creation + * - logWithChanges() — creation with automatic change detection + * - query() — offset-paginated query + * - queryWithCursor() — cursor-paginated query + * - getByActor() — actor-based lookup + * - getByResource() — resource history lookup + * + * All benchmarks use InMemoryAuditRepository to isolate service logic + * from I/O, giving a reliable baseline for the core layer's overhead. + * + * @packageDocumentation + */ + +import { bench, describe, beforeAll } from "vitest"; + +import { AuditService } from "../src/core/audit.service"; +import type { IChangeDetector } from "../src/core/ports/change-detector.port"; +import type { IIdGenerator } from "../src/core/ports/id-generator.port"; +import type { ITimestampProvider } from "../src/core/ports/timestamp-provider.port"; +import { ActorType, AuditActionType } from "../src/core/types"; +import { InMemoryAuditRepository } from "../src/infra/repositories/in-memory/in-memory-audit.repository"; + +// ============================================================================ +// BENCHMARK INFRASTRUCTURE +// ============================================================================ + +let counter = 0; + +/** Minimal ID generator — avoids nanoid ESM overhead in benchmarks. */ +const idGenerator: IIdGenerator = { + generate: (opts) => `${opts?.prefix ?? ""}bench_${++counter}`, + generateBatch: (count, opts) => + Array.from({ length: count }, () => `${opts?.prefix ?? ""}bench_${++counter}`), + isValid: () => true, + extractMetadata: () => null, + getInfo: () => ({ + name: "bench", + version: "1.0.0", + defaultLength: 21, + alphabet: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789", + sortable: false, + encoding: null, + }), +}; + +/** Minimal timestamp provider. */ +const timestampProvider: ITimestampProvider = { + now: () => new Date(), + format: () => "", + parse: () => new Date(), + isValid: () => true, + startOfDay: () => new Date(), + endOfDay: () => new Date(), + diff: () => 0, + freeze: () => {}, + advance: () => {}, + unfreeze: () => {}, + getInfo: () => ({ + name: "bench", + version: "1.0.0", + source: "system-clock", + timezone: "utc" as const, + precision: "millisecond" as const, + frozen: false, + }), +}; + +/** Minimal change detector. */ +const changeDetector: IChangeDetector = { + detectChanges: async (before, after) => { + const changes: Record = {}; + const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]); + for (const key of allKeys) { + if (before[key] !== after[key]) { + changes[key] = { from: before[key], to: after[key] }; + } + } + return changes; + }, + hasChanged: (before, after) => before !== after, + maskValue: () => "***", + formatChanges: (changes) => Object.keys(changes).join(", "), +}; + +/** Sample actor used across benchmarks. */ +const benchActor = { + id: "bench-user-1", + type: ActorType.USER as const, + name: "Bench User", +}; + +/** Sample resource used across benchmarks. */ +const benchResource = { + type: "order", + id: "order-bench-1", +}; + +// ============================================================================ +// log() — SINGLE LOG CREATION +// ============================================================================ + +describe("AuditService.log()", () => { + const repository = new InMemoryAuditRepository(); + const service = new AuditService(repository, idGenerator, timestampProvider, changeDetector); + + bench("log() — create audit log (no options)", async () => { + await service.log({ + actor: benchActor, + action: AuditActionType.UPDATE, + resource: benchResource, + }); + }); + + bench("log() — with metadata and reason", async () => { + await service.log({ + actor: benchActor, + action: AuditActionType.ACCESS, + resource: benchResource, + metadata: { reason: "GDPR request", requestId: "req-123" }, + reason: "User data export", + ipAddress: "192.168.1.1", + }); + }); +}); + +// ============================================================================ +// logWithChanges() — CHANGE DETECTION + CREATION +// ============================================================================ + +describe("AuditService.logWithChanges()", () => { + const repository = new InMemoryAuditRepository(); + const service = new AuditService(repository, idGenerator, timestampProvider, changeDetector); + + const before = { name: "Widget", price: 100, status: "draft", stock: 50 }; + const after = { name: "Widget Pro", price: 120, status: "published", stock: 45 }; + + bench("logWithChanges() — 4 field changes", async () => { + await service.logWithChanges({ + actor: benchActor, + action: AuditActionType.UPDATE, + resource: benchResource, + before, + after, + }); + }); +}); + +// ============================================================================ +// query() — OFFSET PAGINATION +// ============================================================================ + +describe("AuditService.query() — offset pagination", () => { + const repository = new InMemoryAuditRepository(); + const service = new AuditService(repository, idGenerator, timestampProvider, changeDetector); + + // Seed 500 logs before the benchmarks run + beforeAll(async () => { + const seeds = Array.from({ length: 500 }, (_, i) => ({ + id: `seed-${i}`, + timestamp: new Date(Date.now() - i * 1000), + actor: { id: `user-${i % 10}`, type: ActorType.USER as const, name: `User ${i % 10}` }, + action: i % 2 === 0 ? AuditActionType.UPDATE : AuditActionType.CREATE, + resource: { type: "order", id: `order-${i}` }, + })); + await Promise.all(seeds.map((log) => repository.create(log))); + }); + + bench("query() — first page, no filters", async () => { + await service.query({ limit: 20, page: 1 }); + }); + + bench("query() — filtered by actorId", async () => { + await service.query({ actorId: "user-3", limit: 20, page: 1 }); + }); + + bench("query() — filtered by action", async () => { + await service.query({ action: AuditActionType.UPDATE, limit: 20, page: 1 }); + }); +}); + +// ============================================================================ +// queryWithCursor() — CURSOR PAGINATION +// ============================================================================ + +describe("AuditService.queryWithCursor() — cursor pagination", () => { + const repository = new InMemoryAuditRepository(); + const service = new AuditService(repository, idGenerator, timestampProvider, changeDetector); + + let nextCursor: string | undefined; + + beforeAll(async () => { + const seeds = Array.from({ length: 500 }, (_, i) => ({ + id: `cursor-seed-${i}`, + timestamp: new Date(Date.now() - i * 1000), + actor: { id: `user-${i % 5}`, type: ActorType.USER as const, name: `User ${i % 5}` }, + action: AuditActionType.UPDATE, + resource: { type: "document", id: `doc-${i}` }, + })); + await Promise.all(seeds.map((log) => repository.create(log))); + + // Grab a cursor for the "next page" benchmark + const page1 = await service.queryWithCursor({}, { limit: 20 }); + nextCursor = page1.nextCursor; + }); + + bench("queryWithCursor() — first page", async () => { + await service.queryWithCursor({}, { limit: 20 }); + }); + + bench("queryWithCursor() — second page (using cursor)", async () => { + await service.queryWithCursor( + {}, + { limit: 20, ...(nextCursor !== undefined ? { cursor: nextCursor } : {}) }, + ); + }); +}); + +// ============================================================================ +// getByActor() / getByResource() — LOOKUP METHODS +// ============================================================================ + +describe("AuditService — lookup methods", () => { + const repository = new InMemoryAuditRepository(); + const service = new AuditService(repository, idGenerator, timestampProvider, changeDetector); + + beforeAll(async () => { + const seeds = Array.from({ length: 200 }, (_, i) => ({ + id: `lookup-seed-${i}`, + timestamp: new Date(Date.now() - i * 500), + actor: { id: `actor-${i % 5}`, type: ActorType.USER as const, name: `Actor ${i % 5}` }, + action: AuditActionType.UPDATE, + resource: { type: "product", id: `product-${i % 20}` }, + })); + await Promise.all(seeds.map((log) => repository.create(log))); + }); + + bench("getByActor() — actor with ~40 logs", async () => { + await service.getByActor("actor-2"); + }); + + bench("getByResource() — resource with ~10 logs", async () => { + await service.getByResource("product", "product-5"); + }); +}); diff --git a/package-lock.json b/package-lock.json index cb2bf59..629a934 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,8 @@ "devDependencies": { "@changesets/cli": "^2.27.7", "@nestjs/testing": "^11.1.17", + "@stryker-mutator/core": "^9.0.0", + "@stryker-mutator/jest-runner": "^9.0.0", "@types/jest": "^29.5.14", "@types/node": "^22.10.7", "date-fns": "^4.1.0", @@ -30,7 +32,8 @@ "ts-node": "^10.9.2", "tsup": "^8.3.5", "typescript": "^5.7.3", - "typescript-eslint": "^8.50.1" + "typescript-eslint": "^8.50.1", + "vitest": "^3.0.0" }, "engines": { "node": ">=20" @@ -45,14 +48,28 @@ "rxjs": "^7" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -112,14 +129,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -128,6 +145,19 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-compilation-targets": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", @@ -155,6 +185,38 @@ "semver": "bin/semver.js" } }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", @@ -165,30 +227,44 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -197,12 +273,57 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, "engines": { "node": ">=6.9.0" } @@ -252,13 +373,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -267,6 +388,42 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.27.1.tgz", + "integrity": "sha512-DTxe4LBPrtFdsWzgpmbBKevg3e9PBy+dXRt19kSbucbZvL2uqtdqwwpluL1jfxYE0wIDTFp1nTy/q6gNLsxXrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-decorators": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-explicit-resource-management": { + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-explicit-resource-management/-/plugin-proposal-explicit-resource-management-7.27.4.tgz", + "integrity": "sha512-1SwtCDdZWQvUU1i7wt/ihP7W38WjC3CSTOHAl+Xnbze8+bbMNjRvRQydnj0k9J1jPqCAZctBFp6NHJXkrVVmEA==", + "deprecated": "This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-explicit-resource-management instead.", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-syntax-async-generators": { "version": "7.8.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", @@ -322,6 +479,22 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.28.6.tgz", + "integrity": "sha512-71EYI0ONURHJBL4rSFXnITXqXrrY8q4P0q006DPfN+Rk+ASM+++IBXem/ruokgBZR8YNEWZ8R6B+rCb8VcUTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-syntax-import-attributes": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", @@ -491,13 +664,87 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", + "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", - "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz", + "integrity": "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-typescript": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -517,33 +764,33 @@ } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -551,9 +798,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { @@ -1778,15 +2025,28 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@inquirer/external-editor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", - "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", "dev": true, "license": "MIT", "dependencies": { - "chardet": "^2.1.1", - "iconv-lite": "^0.7.0" + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" }, "engines": { "node": ">=18" @@ -1800,65 +2060,395 @@ } } }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" }, "engines": { - "node": ">=8" + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", "dev": true, "license": "MIT", "dependencies": { - "sprintf-js": "~1.0.2" + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "node_modules/@inquirer/core/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", "dev": true, "license": "MIT", "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { "node": ">=8" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", "dev": true, "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "dev": true, "license": "MIT", "dependencies": { @@ -2983,6 +3573,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -2990,15 +3587,28 @@ "dev": true, "license": "MIT" }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "node_modules/@sindresorhus/merge-streams": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" - } + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } }, "node_modules/@sinonjs/fake-timers": { "version": "10.3.0", @@ -3010,6 +3620,375 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@stryker-mutator/api": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/api/-/api-9.0.0.tgz", + "integrity": "sha512-UDgCJDYqhyUr206kXTK3DqwEEt4G60DhY61o77md67Q746XDjidrR1Vn93WdYomHLJYKd9jEwxnJcCQ6wWV8ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mutation-testing-metrics": "3.5.1", + "mutation-testing-report-schema": "3.5.1", + "tslib": "~2.8.0", + "typed-inject": "~5.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stryker-mutator/core": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/core/-/core-9.0.0.tgz", + "integrity": "sha512-Fnt4teVbgOpO+suSsVxF3r6wV1nui79DKchpWS2O9+9p3oiMJLq7/nMthaH9l+VTmD1xFdgRpcgtNdijg0XKsg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@inquirer/prompts": "^7.0.0", + "@stryker-mutator/api": "9.0.0", + "@stryker-mutator/instrumenter": "9.0.0", + "@stryker-mutator/util": "9.0.0", + "ajv": "~8.17.1", + "chalk": "~5.4.0", + "commander": "~13.1.0", + "diff-match-patch": "1.0.5", + "emoji-regex": "~10.4.0", + "execa": "~9.5.0", + "file-url": "~4.0.0", + "lodash.groupby": "~4.6.0", + "minimatch": "~9.0.5", + "mutation-testing-elements": "3.5.2", + "mutation-testing-metrics": "3.5.1", + "mutation-testing-report-schema": "3.5.1", + "npm-run-path": "~6.0.0", + "progress": "~2.0.3", + "rxjs": "~7.8.1", + "semver": "^7.6.3", + "source-map": "~0.7.4", + "tree-kill": "~1.2.2", + "tslib": "2.8.1", + "typed-inject": "~5.0.0", + "typed-rest-client": "~2.1.0" + }, + "bin": { + "stryker": "bin/stryker.js" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stryker-mutator/core/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@stryker-mutator/core/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@stryker-mutator/core/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@stryker-mutator/core/node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@stryker-mutator/core/node_modules/emoji-regex": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", + "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stryker-mutator/core/node_modules/execa": { + "version": "9.5.3", + "resolved": "https://registry.npmjs.org/execa/-/execa-9.5.3.tgz", + "integrity": "sha512-QFNnTvU3UjgWFy8Ef9iDHvIdcgZ344ebkwYx4/KLbR+CKQA4xBaHzv+iRpp86QfMHP8faFQLh8iOc57215y4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/merge-streams": "^4.0.0", + "cross-spawn": "^7.0.3", + "figures": "^6.1.0", + "get-stream": "^9.0.0", + "human-signals": "^8.0.0", + "is-plain-obj": "^4.1.0", + "is-stream": "^4.0.1", + "npm-run-path": "^6.0.0", + "pretty-ms": "^9.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^4.0.0", + "yoctocolors": "^2.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.5.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@stryker-mutator/core/node_modules/get-stream": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-9.0.1.tgz", + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sec-ant/readable-stream": "^0.4.1", + "is-stream": "^4.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@stryker-mutator/core/node_modules/human-signals": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@stryker-mutator/core/node_modules/is-stream": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@stryker-mutator/core/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stryker-mutator/core/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@stryker-mutator/core/node_modules/npm-run-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0", + "unicorn-magic": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@stryker-mutator/core/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@stryker-mutator/core/node_modules/strip-final-newline": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz", + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@stryker-mutator/instrumenter": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/instrumenter/-/instrumenter-9.0.0.tgz", + "integrity": "sha512-hwB8bw7p0gjgTc0SCV+SPmH2l8KZAnm0/hyqs8j+kSVoxH8XeHxMfa4fQTN6U0GSZWRJK15OZZbAtDwANWCEhg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/core": "~7.27.0", + "@babel/generator": "~7.27.0", + "@babel/parser": "~7.27.0", + "@babel/plugin-proposal-decorators": "~7.27.0", + "@babel/plugin-proposal-explicit-resource-management": "^7.24.7", + "@babel/preset-typescript": "~7.27.0", + "@stryker-mutator/api": "9.0.0", + "@stryker-mutator/util": "9.0.0", + "angular-html-parser": "~9.1.0", + "semver": "~7.7.0", + "weapon-regex": "~1.3.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@stryker-mutator/instrumenter/node_modules/@babel/core": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.7.tgz", + "integrity": "sha512-BU2f9tlKQ5CAthiMIgpzAh4eDTLWo1mqi9jqE2OxMG0E/OM199VJt2q8BztTxpnSW0i1ymdwLXRJnYzvDM5r2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.27.7", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.27.7", + "@babel/types": "^7.27.7", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@stryker-mutator/instrumenter/node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@stryker-mutator/instrumenter/node_modules/@babel/generator": { + "version": "7.27.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz", + "integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.27.5", + "@babel/types": "^7.27.3", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@stryker-mutator/instrumenter/node_modules/@babel/parser": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.7.tgz", + "integrity": "sha512-qnzXzDXdr/po3bOTbTIQZ7+TxNKxpkN5IifVLXS+r7qwynkZfPyjZfE7hCXbo7IoO9TNcSyibgONsf2HauUd3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@stryker-mutator/jest-runner": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/jest-runner/-/jest-runner-9.0.0.tgz", + "integrity": "sha512-kPAorgBrlwdW+MSR7x194Ao2bTZjiRG5pux0WNZsHHMFnUeCYO7IG1+WpzumzHWfhAYRh/zO7Ah2V/Tsv13U8A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stryker-mutator/api": "9.0.0", + "@stryker-mutator/util": "9.0.0", + "semver": "~7.7.0", + "tslib": "~2.8.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@stryker-mutator/core": "~9.0.0" + } + }, + "node_modules/@stryker-mutator/util": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@stryker-mutator/util/-/util-9.0.0.tgz", + "integrity": "sha512-o15AQkebTFKDc6m35Bn4eEfZZgm5q4XXWoAUU2eC+PiBQrR5gY+9LofuHsGnV9+eYeg31fjpTm2gy7ZopqpeTw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@tokenizer/inflate": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", @@ -3484,28 +4463,167 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "node_modules/@vitest/expect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.0.tgz", + "integrity": "sha512-Qx+cHyB59mWrQywT3/dZIIpSKwIpWbYFdBX2zixMYpOGZmbaP2jbbd4i/TAKJq/jBgSfww++d6YnrlGMFb2XBg==", "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "dependencies": { + "@vitest/spy": "3.0.0", + "@vitest/utils": "3.0.0", + "chai": "^5.1.2", + "tinyrainbow": "^2.0.0" }, - "engines": { - "node": ">=0.4.0" + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "node_modules/@vitest/mocker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.0.tgz", + "integrity": "sha512-8ytqYjIRzAM90O7n8A0TCbziTnouIG+UGuMHmoRJpKh4vvah4uENw5UAMMNjdKCtzgMiTrZ9XU+xzwCwcxuxGQ==", "dev": true, "license": "MIT", + "dependencies": { + "@vitest/spy": "3.0.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.0.tgz", + "integrity": "sha512-6MCYobtatsgG3DlM+dk6njP+R+28iSUqWbJzXp/nuOy6SkAKzJ1wby3fDgimmy50TeK8g6y+E6rP12REyinYPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.0.0", + "pathe": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.0.tgz", + "integrity": "sha512-W0X6fJFJ3RbSThncSYUNSnXkMJFyXX9sOvxP1HSQRsWCLB1U3JnZc0SrLpLzcyByMUDXHsiXQ+x+xsr/G5fXNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.0.0", + "magic-string": "^0.30.17", + "pathe": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.0.tgz", + "integrity": "sha512-24y+MS04ZHZbbbfAvfpi9hM2oULePbiL6Dir8r1nFMN97hxuL0gEXKWRGmlLPwzKDtaOKNjtyTx0+GiZcWCxDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.0.tgz", + "integrity": "sha512-pfK5O3lRqeCG8mbV+Lr8lLUBicFRm5TlggF7bLZpzpo111LKhMN/tZRXvyOGOgbktxAR9bTf4x8U6RtHuFBTVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.0.tgz", + "integrity": "sha512-l300v2/4diHyv5ZiQOj6y/H6VbaTWM6i1c2lC3lUZ5nn9rv9C+WneS/wqyaGLwM37reoh/QkrrYMSMKdfnDZpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.0.0", + "loupe": "^3.1.2", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.0.tgz", + "integrity": "sha512-24y+MS04ZHZbbbfAvfpi9hM2oULePbiL6Dir8r1nFMN97hxuL0gEXKWRGmlLPwzKDtaOKNjtyTx0+GiZcWCxDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } }, "node_modules/acorn-walk": { "version": "8.3.5", @@ -3537,6 +4655,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/angular-html-parser": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/angular-html-parser/-/angular-html-parser-9.1.1.tgz", + "integrity": "sha512-/xDmnIkfPy7df52scKGGBnZ5Uods64nkf3xBHQSU6uOxwuVVfCFrH+Q/vBZFsc/BY7aJufWtkGjTZrBoyER49w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", @@ -3769,6 +4897,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -4166,6 +5304,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4200,6 +5355,16 @@ "dev": true, "license": "MIT" }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -4318,6 +5483,16 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -4577,6 +5752,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4630,6 +5815,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, "node_modules/detect-indent": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", @@ -4660,6 +5856,13 @@ "node": ">=0.3.1" } }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -4864,6 +6067,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -5287,6 +6497,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -5361,6 +6581,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extendable-error": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/extendable-error/-/extendable-error-0.1.7.tgz", @@ -5426,6 +6656,23 @@ "license": "MIT", "peer": true }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -5464,6 +6711,22 @@ } } }, + "node_modules/figures": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-6.1.0.tgz", + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-unicode-supported": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -5496,6 +6759,19 @@ "url": "https://github.com/sindresorhus/file-type?sponsor=1" } }, + "node_modules/file-url": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/file-url/-/file-url-4.0.0.tgz", + "integrity": "sha512-vRCdScQ6j3Ku6Kd7W1kZk9c++5SqD6Xz5Jotrjr/nkY714M14RFHy/AAVA2WQvpsqVAVgTbDrYyBpU205F0cLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -6417,6 +7693,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -6542,6 +7831,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", @@ -7374,6 +8676,13 @@ "node": ">=10" } }, + "node_modules/js-md4": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", + "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7621,6 +8930,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -7707,6 +9023,13 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7844,6 +9167,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true, + "license": "ISC" + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -8000,6 +9330,40 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mutation-testing-elements": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/mutation-testing-elements/-/mutation-testing-elements-3.5.2.tgz", + "integrity": "sha512-1S6oHiIT3pAYp0mJb8TAyNnaNLHuOJmtDwNEw93bhA0ayjTAPrlNiW8zxivvKD4pjvrZEMUyQCaX+3EBZ4cemw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/mutation-testing-metrics": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/mutation-testing-metrics/-/mutation-testing-metrics-3.5.1.tgz", + "integrity": "sha512-mNgEcnhyBDckgoKg1kjG/4Uo3aBCW0WdVUxINVEazMTggPtqGfxaAlQ9GjItyudu/8S9DuspY3xUaIRLozFG9g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mutation-testing-report-schema": "3.5.1" + } + }, + "node_modules/mutation-testing-report-schema": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/mutation-testing-report-schema/-/mutation-testing-report-schema-3.5.1.tgz", + "integrity": "sha512-tu5ATRxGH3sf2igiTKonxlCsWnWcD3CYr3IXGUym7yTh3Mj5NoJsu7bDkJY99uOrEp6hQByC2nRUPEGfe6EnAg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -8378,6 +9742,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-ms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8443,6 +9820,16 @@ "dev": true, "license": "MIT" }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8450,9 +9837,22 @@ "dev": true, "license": "ISC" }, - "node_modules/pidtree": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", "dev": true, "license": "MIT", @@ -8574,6 +9974,35 @@ "node": ">= 0.4" } }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/postcss-load-config": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", @@ -8617,6 +10046,25 @@ } } }, + "node_modules/postcss/node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8671,6 +10119,32 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/pretty-ms": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse-ms": "^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -8712,6 +10186,22 @@ ], "license": "MIT" }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -8882,6 +10372,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -9071,7 +10571,6 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -9306,6 +10805,13 @@ "dev": true, "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -9382,6 +10888,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/sparse-bitfield": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", @@ -9433,6 +10959,20 @@ "node": ">=8" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -9730,6 +11270,13 @@ "node": ">=0.8" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", @@ -9754,17 +11301,34 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" } }, "node_modules/tmpl": { @@ -10028,14 +11592,14 @@ "node": ">=8" } }, - "node_modules/tsup/node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "engines": { - "node": ">= 12" + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" } }, "node_modules/type-check": { @@ -10152,6 +11716,33 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typed-inject": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/typed-inject/-/typed-inject-5.0.0.tgz", + "integrity": "sha512-0Ql2ORqBORLMdAW89TQKZsb1PQkFGImFfVmncXWe7a+AA3+7dh7Se9exxZowH4kbnlvKEFkMxUYdHUpjYWFJaA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/typed-rest-client": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/typed-rest-client/-/typed-rest-client-2.1.0.tgz", + "integrity": "sha512-Nel9aPbgSzRxfs1+4GoSB4wexCF+4Axlk7OSGVQCMa+4fWcyxIsN/YNmkp0xTT2iQzMD98h8yFLav/cNaULmRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "des.js": "^1.1.0", + "js-md4": "^0.3.2", + "qs": "^6.10.3", + "tunnel": "0.0.6", + "underscore": "^1.12.1" + }, + "engines": { + "node": ">= 16.0.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -10256,6 +11847,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "dev": true, + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -10263,6 +11861,19 @@ "dev": true, "license": "MIT" }, + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -10326,63 +11937,718 @@ "node": ">=10.12.0" } }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "makeerror": "1.0.12" - } - }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" }, "engines": { - "node": ">=18" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "node_modules/vite-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.0.tgz", + "integrity": "sha512-V5p05fpAzkHM3aYChsHWV1RTeLAhPejbKX6MqiWWyuIfNcDgXq5p0GnYV6Wa4OAU588XC70XCJB9chRZsOh4yg==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "isexe": "^2.0.0" + "cac": "^6.7.14", + "debug": "^4.4.0", + "es-module-lexer": "^1.5.4", + "pathe": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0" }, "bin": { - "node-which": "bin/node-which" + "vite-node": "vite-node.mjs" }, "engines": { - "node": ">= 8" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vitest": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.0.tgz", + "integrity": "sha512-fwfPif+EV0jyms9h1Crb6rwJttH/KBzKrcUesjxHgldmc6R0FaMNLsd+Rgc17NoxzLcb/sYE2Xs9NQ/vnTBf6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "3.0.0", + "@vitest/mocker": "3.0.0", + "@vitest/pretty-format": "^3.0.0", + "@vitest/runner": "3.0.0", + "@vitest/snapshot": "3.0.0", + "@vitest/spy": "3.0.0", + "@vitest/utils": "3.0.0", + "chai": "^5.1.2", + "debug": "^4.4.0", + "expect-type": "^1.1.0", + "magic-string": "^0.30.17", + "pathe": "^2.0.0", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinypool": "^1.0.2", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0", + "vite-node": "3.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.0.0", + "@vitest/ui": "3.0.0", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/weapon-regex": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/weapon-regex/-/weapon-regex-1.3.6.tgz", + "integrity": "sha512-wsf1m1jmMrso5nhwVFJJHSubEBf3+pereGd7+nBKtYJ18KoB/PWJOHS3WRkwS04VrOU0iJr2bZU+l1QaTJ+9nA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", @@ -10465,6 +12731,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -10680,6 +12963,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", diff --git a/package.json b/package.json index 2bfa180..4d130d0 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,8 @@ "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage", + "mutation": "stryker run", + "bench": "vitest bench", "changeset": "changeset", "version-packages": "changeset version", "release": "changeset publish", @@ -57,6 +59,8 @@ "devDependencies": { "@changesets/cli": "^2.27.7", "@nestjs/testing": "^11.1.17", + "@stryker-mutator/core": "^9.0.0", + "@stryker-mutator/jest-runner": "^9.0.0", "@types/jest": "^29.5.14", "@types/node": "^22.10.7", "date-fns": "^4.1.0", @@ -73,6 +77,7 @@ "ts-node": "^10.9.2", "tsup": "^8.3.5", "typescript": "^5.7.3", - "typescript-eslint": "^8.50.1" + "typescript-eslint": "^8.50.1", + "vitest": "^3.0.0" } } diff --git a/src/core/audit.service.spec.ts b/src/core/audit.service.spec.ts index c80538a..3aebc8b 100644 --- a/src/core/audit.service.spec.ts +++ b/src/core/audit.service.spec.ts @@ -34,7 +34,6 @@ import type { IIdGenerator } from "./ports/id-generator.port"; import type { ITimestampProvider } from "./ports/timestamp-provider.port"; import type { AuditLog, ChangeSet } from "./types"; import { ActorType, AuditActionType } from "./types"; - // ============================================================================ // MOCK IMPLEMENTATIONS // ============================================================================ @@ -53,6 +52,7 @@ const createMockRepository = (): jest.Mocked => ({ exists: jest.fn(), deleteOlderThan: jest.fn(), archiveOlderThan: jest.fn(), + queryWithCursor: jest.fn(), }); /** @@ -852,4 +852,148 @@ describe("AuditService", () => { ); }); }); + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // queryWithCursor() - Cursor-Based Pagination + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + describe("queryWithCursor()", () => { + it("should return cursor-paginated results when repository supports it", async () => { + // Arrange + const cursorResult = { + data: [expectedAuditLog], + hasMore: true, + limit: 10, + nextCursor: "eyJ0IjoxNzQ2OTk0MDAwMDAwLCJpZCI6ImF1ZGl0XzEyMyJ9", + }; + (mockRepository as any).queryWithCursor = jest.fn().mockResolvedValue(cursorResult); + + // Act + const result = await service.queryWithCursor({}, { limit: 10 }); + + // Assert + expect(result).toEqual(cursorResult); + expect((mockRepository as any).queryWithCursor).toHaveBeenCalledWith(expect.any(Object), { + limit: 10, + }); + }); + + it("should pass filter fields from DTO to repository", async () => { + // Arrange + (mockRepository as any).queryWithCursor = jest.fn().mockResolvedValue({ + data: [], + hasMore: false, + limit: 20, + }); + + // Act + await service.queryWithCursor( + { + actorId: "user-1", + action: AuditActionType.UPDATE, + resourceType: "order", + }, + { limit: 20 }, + ); + + // Assert: filter fields were passed + expect((mockRepository as any).queryWithCursor).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: "user-1", + action: AuditActionType.UPDATE, + resourceType: "order", + }), + { limit: 20 }, + ); + }); + + it("should throw when repository does not support queryWithCursor", async () => { + // Arrange: create a repo mock that explicitly lacks queryWithCursor + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { queryWithCursor: _omit, ...repoWithoutCursor } = createMockRepository(); + const svc = new AuditService( + repoWithoutCursor as any, + mockIdGenerator, + mockTimestampProvider, + ); + + // Act & Assert + await expect(svc.queryWithCursor({})).rejects.toThrow("Cursor pagination is not supported"); + }); + }); + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // Observer - Observability Hooks + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + describe("observer hooks", () => { + it("should call observer.onEvent after a successful log()", async () => { + // Arrange + const onEvent = jest.fn(); + const observerService = new AuditService( + mockRepository, + mockIdGenerator, + mockTimestampProvider, + mockChangeDetector, + { observer: { onEvent } }, + ); + mockRepository.create.mockResolvedValue(expectedAuditLog); + + // Act + await observerService.log(validDto); + + // Assert: observer was called with a success event + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ operation: "create", success: true }), + ); + }); + + it("should call observer.onEvent with an error event when log() fails", async () => { + // Arrange + const onEvent = jest.fn(); + const observerService = new AuditService( + mockRepository, + mockIdGenerator, + mockTimestampProvider, + mockChangeDetector, + { observer: { onEvent } }, + ); + mockRepository.create.mockRejectedValue(new Error("DB error")); + + // Act — log() swallows errors and returns success:false + const result = await observerService.log(validDto); + + // Assert: result is failure + expect(result.success).toBe(false); + + // Assert: observer was called with a fail event + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + operation: "create", + success: false, + error: expect.any(Error), + }), + ); + }); + + it("should not throw if observer.onEvent throws", async () => { + // Arrange: observer that always throws + const breakingObserver = { + onEvent: jest.fn().mockImplementation(() => { + throw new Error("observer error"); + }), + }; + const observerService = new AuditService( + mockRepository, + mockIdGenerator, + mockTimestampProvider, + mockChangeDetector, + { observer: breakingObserver }, + ); + mockRepository.create.mockResolvedValue(expectedAuditLog); + + // Act & Assert: should not throw despite the observer error + await expect(observerService.log(validDto)).resolves.not.toThrow(); + }); + }); }); diff --git a/src/core/audit.service.ts b/src/core/audit.service.ts index 0ab013c..a098e5e 100644 --- a/src/core/audit.service.ts +++ b/src/core/audit.service.ts @@ -30,11 +30,20 @@ import type { CreateAuditLogDto, CreateAuditLogWithChanges, QueryAuditLogsDto } from "./dtos"; import { InvalidActorError, InvalidChangeSetError } from "./errors"; +import type { AuditObserverEvent, IAuditObserver } from "./ports/audit-observer.port"; import type { IAuditLogRepository } from "./ports/audit-repository.port"; import type { IChangeDetector } from "./ports/change-detector.port"; import type { IIdGenerator } from "./ports/id-generator.port"; import type { ITimestampProvider } from "./ports/timestamp-provider.port"; -import type { AuditLog, AuditLogFilters, PageResult, ChangeSet, PageOptions } from "./types"; +import type { + AuditLog, + AuditLogFilters, + CursorPageOptions, + CursorPageResult, + PageResult, + ChangeSet, + PageOptions, +} from "./types"; // ============================================================================ // AUDIT SERVICE RESULT TYPES @@ -114,6 +123,12 @@ export interface AuditServiceOptions { autoCleanupOnWrite?: boolean; archiveBeforeDelete?: boolean; }; + /** + * Optional observability observer. + * Called after each operation with timing and outcome metadata. + * Observer errors are swallowed and never affect core operations. + */ + observer?: IAuditObserver; } // ============================================================================ @@ -283,12 +298,21 @@ export class AuditService { metadata.retention = retentionResult; } + this.notifyObserver({ operation: "create", durationMs: duration, success: true }); + return { success: true, data: created, metadata, }; } catch (error) { + const duration = Date.now() - startTime; + this.notifyObserver({ + operation: "create", + durationMs: duration, + success: false, + error: error instanceof Error ? error : new Error(String(error)), + }); // Return failure result with error details return { success: false, @@ -561,6 +585,72 @@ export class AuditService { return this._repository.query(filters); } + /** + * Queries audit logs using cursor-based (keyset) pagination. + * + * Unlike offset pagination (`query()`), cursor pagination is stable — it + * won't skip or duplicate items when records are inserted between requests. + * Results are sorted by `timestamp DESC, id ASC`. + * + * Requires the configured repository to support `queryWithCursor`. + * + * @param filters - Filter criteria (same fields as `query()`, minus pagination) + * @param cursorOptions - Cursor and limit options + * @returns Cursor-paginated result + * @throws {Error} If the configured repository does not support cursor pagination + * + * @example First page + * ```typescript + * const page1 = await service.queryWithCursor( + * { actorId: 'user-1' }, + * { limit: 10 }, + * ); + * ``` + * + * @example Subsequent page + * ```typescript + * if (page1.hasMore) { + * const page2 = await service.queryWithCursor( + * { actorId: 'user-1' }, + * { limit: 10, cursor: page1.nextCursor }, + * ); + * } + * ``` + */ + async queryWithCursor( + filters: Partial, + cursorOptions?: CursorPageOptions, + ): Promise> { + const startTime = Date.now(); + try { + if (!this._repository.queryWithCursor) { + throw new Error( + "Cursor pagination is not supported by the configured repository. " + + "Ensure your repository adapter implements queryWithCursor().", + ); + } + + const result = await this._repository.queryWithCursor(filters, cursorOptions); + + this.notifyObserver({ + operation: "queryWithCursor", + durationMs: Date.now() - startTime, + success: true, + meta: { count: result.data.length, hasMore: result.hasMore }, + }); + + return result; + } catch (error) { + this.notifyObserver({ + operation: "queryWithCursor", + durationMs: Date.now() - startTime, + success: false, + error: error instanceof Error ? error : new Error(String(error)), + }); + throw error; + } + } + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // CHANGE DETECTION - Comparing Object States // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -787,4 +877,26 @@ export class AuditService { return value; } + + /** + * Notifies the configured observer with an operation event. + * + * Errors thrown by the observer are swallowed intentionally — observability + * hooks must never disrupt core audit operations. + */ + private notifyObserver(event: AuditObserverEvent): void { + if (!this._options?.observer) { + return; + } + try { + const result = this._options.observer.onEvent(event); + if (result instanceof Promise) { + result.catch(() => { + // Observer async errors are intentionally ignored + }); + } + } catch { + // Observer sync errors are intentionally ignored + } + } } diff --git a/src/core/index.ts b/src/core/index.ts index 4178df0..4775ceb 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -49,9 +49,15 @@ export { // Main Entity - Audit Log type AuditLog, - // Query & Pagination Types + // Offset Pagination Types type PageOptions, type PageResult, + + // Cursor Pagination Types + type CursorPageOptions, + type CursorPageResult, + + // Filter Types type AuditLogFilters, // Type Guards - Runtime type checking @@ -124,6 +130,11 @@ export { type TimestampFormat, type TimezoneOption, type TimestampProviderInfo, + + // Audit Observer Port - Observability hooks + type IAuditObserver, + type AuditObserverEvent, + type AuditOperationType, } from "./ports"; // ============================================================================ diff --git a/src/core/ports/audit-observer.port.ts b/src/core/ports/audit-observer.port.ts new file mode 100644 index 0000000..18b17b7 --- /dev/null +++ b/src/core/ports/audit-observer.port.ts @@ -0,0 +1,136 @@ +/** + * ============================================================================ + * AUDIT OBSERVER PORT - OBSERVABILITY HOOKS + * ============================================================================ + * + * Port interface for plugging in observability integrations + * (OpenTelemetry, Prometheus, Datadog, custom logging, etc.). + * + * This port intentionally keeps consumer-facing: AuditKit calls `onEvent()` + * after each significant operation so that consumers can emit spans, metrics, + * or structured log events without AuditKit depending on any specific SDK. + * + * SECURITY: Event objects deliberately exclude audit log content, actor PII, + * and resource metadata — they carry only timing and operational metadata. + * + * Architecture: + * - This is a PORT (interface) defined in core/ — framework-free + * - Concrete implementations live in infra/ or in consuming applications + * - Wire the observer via AuditKitModuleOptions.observer + * + * @packageDocumentation + */ + +// ESLint disable for interface method parameters (they're part of the contract) +/* eslint-disable no-unused-vars */ + +// ============================================================================ +// OPERATION TYPES +// ============================================================================ + +/** + * Names of operations emitted as events by AuditService. + */ +export type AuditOperationType = + | "create" + | "createWithChanges" + | "query" + | "queryWithCursor" + | "getById" + | "getByActor" + | "getByResource" + | "detectChanges"; + +// ============================================================================ +// EVENT TYPES +// ============================================================================ + +/** + * Observability event emitted after each AuditService operation. + * + * Designed to be forwarded to OpenTelemetry tracer spans, Prometheus + * counters/histograms, structured loggers, or any custom sink. + * + * @example OpenTelemetry integration + * ```typescript + * class OtelAuditObserver implements IAuditObserver { + * async onEvent(event: AuditObserverEvent): Promise { + * const span = tracer.startSpan(`audit.${event.operation}`, { + * attributes: { + * 'audit.success': event.success, + * 'audit.duration_ms': event.durationMs, + * ...event.meta, + * }, + * }); + * if (!event.success && event.error) { + * span.recordException(event.error); + * } + * span.end(); + * } + * } + * ``` + */ +export interface AuditObserverEvent { + /** + * The AuditService operation that produced this event. + */ + operation: AuditOperationType; + + /** + * Wall-clock duration of the operation in milliseconds. + */ + durationMs: number; + + /** + * Whether the operation completed successfully. + */ + success: boolean; + + /** + * The caught error if `success` is false. + */ + error?: Error; + + /** + * Safe key/value metadata (no PII, no raw entity content). + * + * Examples: `{ 'result.count': 25, 'idempotent_hit': true }`. + */ + meta?: Record; +} + +// ============================================================================ +// OBSERVER PORT +// ============================================================================ + +/** + * Port for observability integrations with AuditService. + * + * Implement this interface to subscribe to operation events without + * coupling AuditKit to a specific observability library. + * + * Key guarantees: + * - Called after every `AuditService` operation (success and failure) + * - Observer errors are swallowed — they never affect core operations + * - Events contain no PII or sensitive audit log content + * + * @example Custom metrics observer + * ```typescript + * class MetricsAuditObserver implements IAuditObserver { + * onEvent(event: AuditObserverEvent): void { + * metricsClient.histogram('audit.operation.duration', event.durationMs, { + * operation: event.operation, + * success: String(event.success), + * }); + * } + * } + * ``` + */ +export interface IAuditObserver { + /** + * Called after each AuditService operation. + * + * @param event - Observability event with timing and outcome metadata + */ + onEvent(_event: AuditObserverEvent): void | Promise; +} diff --git a/src/core/ports/audit-repository.port.ts b/src/core/ports/audit-repository.port.ts index 727dedb..6507034 100644 --- a/src/core/ports/audit-repository.port.ts +++ b/src/core/ports/audit-repository.port.ts @@ -24,7 +24,14 @@ * @packageDocumentation */ -import type { AuditLog, AuditLogFilters, PageOptions, PageResult } from "../types"; +import type { + AuditLog, + AuditLogFilters, + CursorPageOptions, + CursorPageResult, + PageOptions, + PageResult, +} from "../types"; // ESLint disable for interface method parameters (they're part of the contract, not actual code) /* eslint-disable no-unused-vars */ @@ -284,4 +291,39 @@ export interface IAuditLogRepository { * @throws Error if archival fails or not supported */ archiveOlderThan?(_beforeDate: Date): Promise; + + /** + * Queries audit logs using cursor-based pagination. + * + * Unlike offset pagination (`query()`), cursor pagination is stable: + * it won't skip or duplicate items when records are inserted or deleted + * between pages. Results are always sorted by `timestamp DESC, id ASC`. + * + * @param filters - Filter criteria (same as `query()`, except page/limit/sort are ignored) + * @param options - Cursor and limit options + * @returns Cursor-paginated result with an opaque `nextCursor` for the next page + * @throws Error if the cursor is invalid or query execution fails + * + * @example First page + * ```typescript + * const page1 = await repository.queryWithCursor( + * { actorId: 'user-1' }, + * { limit: 10 }, + * ); + * ``` + * + * @example Next page + * ```typescript + * if (page1.hasMore) { + * const page2 = await repository.queryWithCursor( + * { actorId: 'user-1' }, + * { limit: 10, cursor: page1.nextCursor }, + * ); + * } + * ``` + */ + queryWithCursor?( + _filters: Partial, + _options?: CursorPageOptions, + ): Promise>; } diff --git a/src/core/ports/index.ts b/src/core/ports/index.ts index a4f5a38..4284210 100644 --- a/src/core/ports/index.ts +++ b/src/core/ports/index.ts @@ -63,3 +63,13 @@ export { type TimezoneOption, type TimestampProviderInfo, } from "./timestamp-provider.port"; + +// ============================================================================ +// AUDIT OBSERVER PORT - Observability Hooks +// ============================================================================ + +export { + type IAuditObserver, + type AuditObserverEvent, + type AuditOperationType, +} from "./audit-observer.port"; diff --git a/src/core/types.ts b/src/core/types.ts index 7c1a676..8007de8 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -304,6 +304,47 @@ export interface PageResult { pages: number; } +/** + * Options for cursor-based pagination. + * + * Cursor pagination avoids the instability of offset-based pagination + * (e.g., skipped or duplicated items when records are added/removed mid-query). + * Results are always sorted by `timestamp DESC, id ASC`. + */ +export interface CursorPageOptions { + /** + * Opaque cursor string returned from the previous page's `nextCursor`. + * Omit to retrieve the first page. + */ + cursor?: string; + + /** + * Maximum number of items per page. + * @default 20 + */ + limit?: number; +} + +/** + * Result of a cursor-based paginated query. + */ +export interface CursorPageResult { + /** Items for the current page */ + data: T[]; + + /** + * Opaque cursor for the next page. + * Absent when `hasMore` is false — do not pass to the next call. + */ + nextCursor?: string; + + /** Whether more items exist after this page */ + hasMore: boolean; + + /** Effective page size used for this result */ + limit: number; +} + /** * Filter options for querying audit logs. * diff --git a/src/infra/repositories/cursor.util.ts b/src/infra/repositories/cursor.util.ts new file mode 100644 index 0000000..c918ae5 --- /dev/null +++ b/src/infra/repositories/cursor.util.ts @@ -0,0 +1,64 @@ +/** + * ============================================================================ + * CURSOR UTILITY - OPAQUE CURSOR ENCODING FOR CURSOR PAGINATION + * ============================================================================ + * + * Provides encode/decode helpers that turn an internal `{ t, id }` pair into + * an opaque base64url string that is safe to surface in API responses. + * + * Cursor format (before encoding): + * { t: number, id: string } + * where `t` is the timestamp in milliseconds and `id` is the audit log ID. + * + * Why base64url? Pure ascii, URL-safe, and hides the internal structure from + * API consumers (opaque cursor contract). + * + * @packageDocumentation + */ + +/** + * Internal cursor data (before encoding). + */ +export interface CursorData { + /** Timestamp in milliseconds */ + t: number; + /** Audit log ID */ + id: string; +} + +/** + * Encodes a cursor data object into an opaque base64url string. + * + * @param data - The cursor data to encode + * @returns Base64url-encoded cursor string + */ +export function encodeCursor(data: CursorData): string { + return Buffer.from(JSON.stringify(data)).toString("base64url"); +} + +/** + * Decodes an opaque cursor string back into cursor data. + * + * @param cursor - The base64url cursor string + * @returns Decoded cursor data + * @throws Error if the cursor is malformed + */ +export function decodeCursor(cursor: string): CursorData { + try { + const json = Buffer.from(cursor, "base64url").toString("utf8"); + const parsed = JSON.parse(json) as unknown; + + if ( + typeof parsed !== "object" || + parsed === null || + typeof (parsed as Record)["t"] !== "number" || + typeof (parsed as Record)["id"] !== "string" + ) { + throw new Error("Cursor has unexpected shape"); + } + + return parsed as CursorData; + } catch { + throw new Error(`Invalid pagination cursor: "${cursor}"`); + } +} diff --git a/src/infra/repositories/in-memory/in-memory-audit.repository.ts b/src/infra/repositories/in-memory/in-memory-audit.repository.ts index 7736308..f8fae91 100644 --- a/src/infra/repositories/in-memory/in-memory-audit.repository.ts +++ b/src/infra/repositories/in-memory/in-memory-audit.repository.ts @@ -32,7 +32,15 @@ */ import type { IAuditLogRepository } from "../../../core/ports/audit-repository.port"; -import type { AuditLog, AuditLogFilters, PageOptions, PageResult } from "../../../core/types"; +import type { + AuditLog, + AuditLogFilters, + CursorPageOptions, + CursorPageResult, + PageOptions, + PageResult, +} from "../../../core/types"; +import { decodeCursor, encodeCursor } from "../cursor.util"; // eslint-disable-next-line no-unused-vars type ArchiveHandler = (logs: AuditLog[]) => Promise | void; @@ -292,6 +300,60 @@ export class InMemoryAuditRepository implements IAuditLogRepository { // UTILITY METHODS (Testing Support) // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + /** + * Queries audit logs with cursor-based pagination. + * + * Results are sorted by `timestamp DESC, id ASC` for consistency with the + * MongoDB adapter. A base64url cursor encodes `{ t, id }` of the last item. + * + * @param filters - Filter criteria + * @param options - Cursor and limit options + * @returns Cursor-paginated result + */ + async queryWithCursor( + filters: Partial, + options?: CursorPageOptions, + ): Promise> { + const limit = options?.limit ?? 20; + + // Apply filters and sort: timestamp DESC, id ASC + let sorted = Array.from(this.logs.values()) + .filter((log) => this.matchesFilters(log, filters)) + .sort((a, b) => { + const timeDiff = b.timestamp.getTime() - a.timestamp.getTime(); + if (timeDiff !== 0) return timeDiff; + return a.id.localeCompare(b.id); + }); + + // Apply cursor constraint (skip everything up to and including the cursor item) + if (options?.cursor) { + const cursorData = decodeCursor(options.cursor); + const idx = sorted.findIndex( + (log) => + log.timestamp.getTime() < cursorData.t || + (log.timestamp.getTime() === cursorData.t && log.id > cursorData.id), + ); + sorted = idx >= 0 ? sorted.slice(idx) : []; + } + + const hasMore = sorted.length > limit; + const page = sorted.slice(0, limit); + const data = page.map((log) => this.deepCopy(log)); + + const lastItem = data[data.length - 1]; + const result: CursorPageResult = { + data, + hasMore, + limit, + }; + + if (hasMore && lastItem) { + result.nextCursor = encodeCursor({ t: lastItem.timestamp.getTime(), id: lastItem.id }); + } + + return result; + } + /** * Clears all audit logs. * Useful for cleanup between tests. diff --git a/src/infra/repositories/mongodb/mongo-audit.repository.ts b/src/infra/repositories/mongodb/mongo-audit.repository.ts index c16c0aa..fefceab 100644 --- a/src/infra/repositories/mongodb/mongo-audit.repository.ts +++ b/src/infra/repositories/mongodb/mongo-audit.repository.ts @@ -22,7 +22,15 @@ import type { Model } from "mongoose"; import type { IAuditLogRepository } from "../../../core/ports/audit-repository.port"; -import type { AuditLog, AuditLogFilters, PageOptions, PageResult } from "../../../core/types"; +import type { + AuditLog, + AuditLogFilters, + CursorPageOptions, + CursorPageResult, + PageOptions, + PageResult, +} from "../../../core/types"; +import { decodeCursor, encodeCursor } from "../cursor.util"; import type { AuditLogDocument } from "./audit-log.schema"; @@ -243,6 +251,63 @@ export class MongoAuditRepository implements IAuditLogRepository { return logs.length; } + /** + * Queries audit logs using cursor-based pagination. + * + * Results are sorted by `timestamp DESC, id ASC`. + * The cursor encodes the `{ timestamp, id }` of the last returned item. + * + * @param filters - Filter criteria + * @param options - Cursor and limit options + * @returns Cursor-paginated result + */ + async queryWithCursor( + filters: Partial, + options?: CursorPageOptions, + ): Promise> { + const limit = options?.limit ?? 20; + const query = this.buildQuery(filters); + + // Apply cursor constraint when provided + if (options?.cursor) { + const cursorData = decodeCursor(options.cursor); + const cursorDate = new Date(cursorData.t); + const cursorId = cursorData.id; + + // "After cursor" in descending-timestamp + ascending-id order: + // timestamp < cursorDate OR (timestamp == cursorDate AND id > cursorId) + query["$or"] = [ + { timestamp: { $lt: cursorDate } }, + { timestamp: cursorDate, id: { $gt: cursorId } }, + ]; + } + + // Fetch limit+1 to detect whether more pages exist + const documents = await this.model + .find(query) + .sort({ timestamp: -1, id: 1 }) + .limit(limit + 1) + .lean() + .exec(); + + const hasMore = documents.length > limit; + const pageDocuments = documents.slice(0, limit); + const data = pageDocuments.map((doc) => this.toPlainObject(doc)); + + const lastItem = data[data.length - 1]; + const result: CursorPageResult = { + data, + hasMore, + limit, + }; + + if (hasMore && lastItem) { + result.nextCursor = encodeCursor({ t: lastItem.timestamp.getTime(), id: lastItem.id }); + } + + return result; + } + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // PRIVATE HELPER METHODS // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/src/nest/interfaces.ts b/src/nest/interfaces.ts index d97c772..4c06341 100644 --- a/src/nest/interfaces.ts +++ b/src/nest/interfaces.ts @@ -15,6 +15,7 @@ import type { ModuleMetadata, Type } from "@nestjs/common"; import type { Model } from "mongoose"; +import type { IAuditObserver } from "../core/ports/audit-observer.port"; import type { AuditLog } from "../core/types"; import type { AuditLogDocument } from "../infra/repositories/mongodb/audit-log.schema"; @@ -268,6 +269,25 @@ export interface AuditKitModuleOptions { * Retention and archival policy. */ retention?: RetentionConfig; + + /** + * Observability observer (OpenTelemetry, metrics, custom logging, etc.). + * When provided, AuditService calls `observer.onEvent()` after each operation. + * Observer errors are swallowed and never affect core operations. + * + * @example + * ```typescript + * AuditKitModule.register({ + * repository: { type: 'in-memory' }, + * observer: { + * onEvent(event) { + * console.log(`[audit] ${event.operation} in ${event.durationMs}ms`); + * }, + * }, + * }); + * ``` + */ + observer?: IAuditObserver; } // ============================================================================ diff --git a/src/nest/options.validation.ts b/src/nest/options.validation.ts index 9224dfb..ea67a9c 100644 --- a/src/nest/options.validation.ts +++ b/src/nest/options.validation.ts @@ -8,6 +8,8 @@ * @packageDocumentation */ +import type { IAuditObserver } from "../core/ports/audit-observer.port"; + import type { AuditKitModuleOptions } from "./interfaces"; /** @@ -29,6 +31,8 @@ export interface AuditServiceRuntimeOptions { autoCleanupOnWrite?: boolean; archiveBeforeDelete?: boolean; }; + /** Observability observer wired from module options. */ + observer?: IAuditObserver; } /** @@ -99,6 +103,10 @@ export function toAuditServiceRuntimeOptions( runtimeOptions.retention = retention; } + if (options.observer) { + runtimeOptions.observer = options.observer; + } + return runtimeOptions; } diff --git a/stryker.config.json b/stryker.config.json new file mode 100644 index 0000000..951ff83 --- /dev/null +++ b/stryker.config.json @@ -0,0 +1,31 @@ +{ + "packageManager": "npm", + "testRunner": "jest", + "mutate": [ + "src/core/audit.service.ts", + "src/core/dtos/**/*.ts", + "src/core/errors/**/*.ts", + "src/infra/repositories/**/*.ts", + "src/nest/options.validation.ts" + ], + "reporters": ["html", "progress"], + "coverageAnalysis": "perTest", + "thresholds": { + "high": 80, + "low": 60, + "break": 0 + }, + "jest": { + "projectType": "custom", + "config": { + "testEnvironment": "node", + "transform": { + "^.+\\.ts$": ["ts-jest", { "tsconfig": "tsconfig.json" }] + }, + "transformIgnorePatterns": ["node_modules/(?!(nanoid)/)"], + "testMatch": ["/src/**/*.spec.ts"] + } + }, + "ignorePatterns": ["src/**/index.ts", "src/**/*.d.ts"], + "tsconfigFile": "tsconfig.json" +} diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 26f1fe1..08cfc98 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -1,5 +1,12 @@ { "extends": "./tsconfig.json", - "include": ["src/**/*.ts", "test/**/*.ts", "__mocks__/**/*.ts", "*.ts", "*.js"], + "include": [ + "src/**/*.ts", + "test/**/*.ts", + "benchmarks/**/*.ts", + "__mocks__/**/*.ts", + "*.ts", + "*.js" + ], "exclude": ["dist", "node_modules"] } diff --git a/tsconfig.json b/tsconfig.json index e1060c9..0bfe6d8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,10 +14,10 @@ "esModuleInterop": true, "resolveJsonModule": true, "skipLibCheck": true, - "types": ["jest"], + "types": ["jest", "node"], "baseUrl": ".", "ignoreDeprecations": "5.0" }, - "include": ["src/**/*.ts", "test/**/*.ts"], + "include": ["src/**/*.ts", "test/**/*.ts", "benchmarks/**/*.ts"], "exclude": ["dist", "node_modules"] } diff --git a/vitest.config.ts b/vitest.config.ts index 639dd47..60d0ef5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -16,5 +16,9 @@ export default defineConfig({ }, globals: true, watch: false, + benchmark: { + include: ["benchmarks/**/*.bench.ts"], + exclude: ["**/node_modules/**"], + }, }, }); From ee86de4d7305f9be356d3930f0ffc09ea715624b Mon Sep 17 00:00:00 2001 From: yasser Date: Thu, 26 Mar 2026 17:43:43 +0100 Subject: [PATCH 4/8] feat: add event streaming, docs updates, and CI compatibility matrix --- .github/workflows/pr-validation.yml | 11 +- README.md | 163 +++++++++++++++--- docs/ARCHITECTURE.md | 26 +++ docs/RELEASE.md | 20 +++ src/core/audit.service.spec.ts | 47 +++++ src/core/audit.service.ts | 40 +++++ src/core/index.ts | 6 + src/core/ports/audit-event-publisher.port.ts | 58 +++++++ src/core/ports/index.ts | 11 ++ .../event-emitter-audit-event.publisher.ts | 51 ++++++ src/infra/providers/events/index.ts | 1 + src/infra/providers/index.ts | 1 + src/nest/interfaces.ts | 20 +++ src/nest/module.spec.ts | 16 ++ src/nest/module.ts | 8 +- src/nest/options.validation.ts | 12 ++ src/nest/providers.ts | 8 +- 17 files changed, 471 insertions(+), 28 deletions(-) create mode 100644 src/core/ports/audit-event-publisher.port.ts create mode 100644 src/infra/providers/events/event-emitter-audit-event.publisher.ts create mode 100644 src/infra/providers/events/index.ts diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index fc872ed..db75d25 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -9,8 +9,13 @@ permissions: jobs: validate: - name: CI - PR Validation - runs-on: ubuntu-latest + name: CI - PR Validation (Node ${{ matrix.node-version }} / ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + node-version: [20, 22] steps: - name: Checkout @@ -19,7 +24,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: - node-version: 20 + node-version: ${{ matrix.node-version }} cache: npm - name: Install diff --git a/README.md b/README.md index 6f35ec5..64450ed 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,148 @@ -# NestJS DeveloperKit (Template) +# @ciscode/audit-kit -Template repository for building reusable NestJS npm packages. +AuditKit is a reusable NestJS module for immutable audit logging with clean architecture (`core` / `infra` / `nest`). -## What you get +It provides: -- ESM + CJS + Types build (tsup) -- Jest testing -- ESLint + Prettier -- Changesets (Release PR flow) -- Husky (pre-commit + pre-push) -- Enforced package architecture (core / infra / nest) with strict public API +- Framework-free core service (`AuditService`) +- Pluggable repositories (MongoDB, in-memory) +- Automatic change detection +- Configurable redaction, idempotency, and retention policies +- Cursor-based pagination for stable listing +- Observability hooks (OpenTelemetry-friendly observer port) +- Event streaming hooks (publisher port + default EventEmitter adapter) -## Scripts +## Install -- `npm run build` – build to `dist/` -- `npm test` – run tests -- `npm run typecheck` – TypeScript typecheck -- `npm run lint` – ESLint -- `npm run format` / `npm run format:write` – Prettier -- `npx changeset` – create a changeset +```bash +npm install @ciscode/audit-kit +``` -## Release flow (summary) +Peer dependencies are managed by consuming applications. -- Work on a `feature` branch from `develop` -- Merge to `develop` -- Add a changeset for user-facing changes: `npx changeset` -- Automation opens “Version Packages” PR into `master` -- Merge to `master`, tag `vX.Y.Z` to publish +## Quick Start -See `docs/RELEASE.md` for details. +```typescript +import { Module } from "@nestjs/common"; +import { AuditKitModule } from "@ciscode/audit-kit"; + +@Module({ + imports: [ + AuditKitModule.register({ + repository: { + type: "in-memory", + }, + redaction: { + enabled: true, + fields: ["actor.email", "metadata.password"], + mask: "[REDACTED]", + }, + idempotency: { + enabled: true, + keyStrategy: "idempotencyKey", + }, + }), + ], +}) +export class AppModule {} +``` + +## Usage + +```typescript +import { Injectable } from "@nestjs/common"; +import { ActorType, AuditActionType, AuditService } from "@ciscode/audit-kit"; + +@Injectable() +export class UserService { + constructor(private readonly auditService: AuditService) {} + + async updateUser(): Promise { + await this.auditService.log({ + actor: { id: "user-1", type: ActorType.USER, email: "user@example.com" }, + action: AuditActionType.UPDATE, + resource: { type: "user", id: "user-1" }, + metadata: { reason: "profile update" }, + idempotencyKey: "req-123", + }); + } +} +``` + +## Advanced Features + +### Cursor Pagination + +```typescript +const page1 = await auditService.queryWithCursor({ actorId: "user-1" }, { limit: 20 }); + +if (page1.hasMore) { + const page2 = await auditService.queryWithCursor( + { actorId: "user-1" }, + { limit: 20, cursor: page1.nextCursor }, + ); +} +``` + +### Observability Hooks + +Provide an observer to emit metrics/spans/log events (for OpenTelemetry, Prometheus, etc.): + +```typescript +AuditKitModule.register({ + repository: { type: "in-memory" }, + observer: { + onEvent(event) { + // event.operation, event.durationMs, event.success + console.log(event); + }, + }, +}); +``` + +### Event Streaming + +Emit domain events after successful audit creation: + +```typescript +AuditKitModule.register({ + repository: { type: "in-memory" }, + eventStreaming: { + enabled: true, + // optional custom publisher; default is EventEmitter adapter + publisher: { + publish(event) { + console.log(event.type, event.payload.id); + }, + }, + }, +}); +``` + +## Tooling + +- Tests: `npm test` +- Typecheck: `npm run typecheck` +- Lint: `npm run lint` +- Build: `npm run build` +- Mutation testing: `npm run mutation` +- Benchmarks: `npm run bench` + +## CI Compatibility Matrix + +PR validation runs on a matrix to catch environment regressions early: + +- Node.js: 20, 22 +- OS: ubuntu-latest, windows-latest + +See [.github/workflows/pr-validation.yml](.github/workflows/pr-validation.yml). + +## Release Flow (Summary) + +1. Work on a feature branch from `develop` +2. Add a changeset for user-facing changes: `npx changeset` +3. Merge into `develop` +4. Automation opens "Version Packages" PR into `master` +5. Merge and publish + +See [docs/RELEASE.md](docs/RELEASE.md) for details. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 383fe83..cbb6c51 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,12 +2,19 @@ This repository is a template for NestJS _packages_ (not apps). +AuditKit is implemented as a CSR-style modular package with ports/adapters. + ## Layers (inside `src/`) - `core/`: pure logic (no Nest imports) + - Ports: repository, change detector, observer, event publisher + - Service: `AuditService` - `infra/`: adapters/implementations (may depend on `core/`) + - Repositories: MongoDB, in-memory + - Providers: id generator, timestamp, change detector, event publisher (EventEmitter) - Internal by default (not exported publicly) - `nest/`: Nest module wiring (DI, providers, DynamicModule) + - Validation and runtime option mapping ## Dependency Rules @@ -21,3 +28,22 @@ Consumers import only from the package root entrypoint. All public exports must go through `src/index.ts`. Folders like `infra/` are internal unless explicitly re-exported. + +## Data Flow + +1. App calls `AuditService.log()` or `AuditService.logWithChanges()`. +2. Core validates actor, applies idempotency and redaction policies. +3. Repository persists immutable audit entries. +4. Optional retention/archival cleanup runs after write. +5. Optional observer hook emits operation telemetry metadata. +6. Optional event publisher emits `audit.log.created` to a stream. + +## Querying Models + +- Offset pagination: `query()` for page/limit/sort use-cases. +- Cursor pagination: `queryWithCursor()` for stable keyset iteration. + +Cursor pagination uses an opaque cursor encoding `{ timestamp, id }` and sorts by: + +- `timestamp DESC` +- `id ASC` diff --git a/docs/RELEASE.md b/docs/RELEASE.md index f6fc8b1..48ccf47 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -18,6 +18,26 @@ Changesets controls versions and changelog via a “Version Packages” PR. 3. If it affects users, run: `npx changeset` 4. Open PR → `develop` +## Quality Gates (before PR) + +Run locally before opening a PR: + +1. `npm run typecheck` +2. `npm run lint` +3. `npm test` +4. Optional advanced checks: + - `npm run mutation` + - `npm run bench` + +## CI Compatibility Matrix + +PR validation runs across: + +- Node.js 20 and 22 +- Ubuntu and Windows runners + +This matrix helps detect runtime and tooling regressions before merge. + ## Release 1. Automation opens “Version Packages” PR from `develop` → `master` diff --git a/src/core/audit.service.spec.ts b/src/core/audit.service.spec.ts index 3aebc8b..f085fc4 100644 --- a/src/core/audit.service.spec.ts +++ b/src/core/audit.service.spec.ts @@ -996,4 +996,51 @@ describe("AuditService", () => { await expect(observerService.log(validDto)).resolves.not.toThrow(); }); }); + + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + // Event Publisher - Event Streaming Hooks + // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + describe("event publisher hooks", () => { + it("should publish audit.log.created after successful create", async () => { + // Arrange + const publish = jest.fn(); + const eventService = new AuditService( + mockRepository, + mockIdGenerator, + mockTimestampProvider, + mockChangeDetector, + { eventPublisher: { publish } }, + ); + mockRepository.create.mockResolvedValue(expectedAuditLog); + + // Act + await eventService.log(validDto); + + // Assert + expect(publish).toHaveBeenCalledWith( + expect.objectContaining({ type: "audit.log.created", payload: expectedAuditLog }), + ); + }); + + it("should not throw if event publisher throws", async () => { + // Arrange + const eventPublisher = { + publish: jest.fn().mockImplementation(() => { + throw new Error("publisher error"); + }), + }; + const eventService = new AuditService( + mockRepository, + mockIdGenerator, + mockTimestampProvider, + mockChangeDetector, + { eventPublisher }, + ); + mockRepository.create.mockResolvedValue(expectedAuditLog); + + // Act & Assert + await expect(eventService.log(validDto)).resolves.not.toThrow(); + }); + }); }); diff --git a/src/core/audit.service.ts b/src/core/audit.service.ts index a098e5e..b07e211 100644 --- a/src/core/audit.service.ts +++ b/src/core/audit.service.ts @@ -30,6 +30,11 @@ import type { CreateAuditLogDto, CreateAuditLogWithChanges, QueryAuditLogsDto } from "./dtos"; import { InvalidActorError, InvalidChangeSetError } from "./errors"; +import { + AUDIT_EVENT_TYPES, + type AuditEvent, + type IAuditEventPublisher, +} from "./ports/audit-event-publisher.port"; import type { AuditObserverEvent, IAuditObserver } from "./ports/audit-observer.port"; import type { IAuditLogRepository } from "./ports/audit-repository.port"; import type { IChangeDetector } from "./ports/change-detector.port"; @@ -129,6 +134,12 @@ export interface AuditServiceOptions { * Observer errors are swallowed and never affect core operations. */ observer?: IAuditObserver; + + /** + * Optional audit event publisher for event streaming integrations. + * Publisher errors are swallowed and never affect core operations. + */ + eventPublisher?: IAuditEventPublisher; } // ============================================================================ @@ -298,6 +309,7 @@ export class AuditService { metadata.retention = retentionResult; } + this.publishAuditCreatedEvent(created); this.notifyObserver({ operation: "create", durationMs: duration, success: true }); return { @@ -899,4 +911,32 @@ export class AuditService { // Observer sync errors are intentionally ignored } } + + /** + * Publishes a created audit-log event through the configured publisher. + * + * Errors are swallowed intentionally to avoid impacting business logic. + */ + private publishAuditCreatedEvent(log: AuditLog): void { + if (!this._options?.eventPublisher) { + return; + } + + const event: AuditEvent = { + type: AUDIT_EVENT_TYPES.CREATED, + emittedAt: new Date(), + payload: log, + }; + + try { + const result = this._options.eventPublisher.publish(event); + if (result instanceof Promise) { + result.catch(() => { + // Publisher async errors are intentionally ignored + }); + } + } catch { + // Publisher sync errors are intentionally ignored + } + } } diff --git a/src/core/index.ts b/src/core/index.ts index 4775ceb..9c81899 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -135,6 +135,12 @@ export { type IAuditObserver, type AuditObserverEvent, type AuditOperationType, + + // Audit Event Publisher Port - Event streaming + AUDIT_EVENT_TYPES, + type IAuditEventPublisher, + type AuditEvent, + type AuditEventType, } from "./ports"; // ============================================================================ diff --git a/src/core/ports/audit-event-publisher.port.ts b/src/core/ports/audit-event-publisher.port.ts new file mode 100644 index 0000000..a886a72 --- /dev/null +++ b/src/core/ports/audit-event-publisher.port.ts @@ -0,0 +1,58 @@ +/** + * ============================================================================ + * AUDIT EVENT PUBLISHER PORT - EVENT STREAMING ABSTRACTION + * ============================================================================ + * + * Port interface for emitting audit lifecycle events to external event + * streaming systems (Kafka, RabbitMQ, NATS, SNS/SQS, etc.). + * + * This keeps core logic independent from any specific event bus SDK. + * + * @packageDocumentation + */ + +import type { AuditLog } from "../types"; + +// ESLint disable for interface method parameters (they're part of the contract) +/* eslint-disable no-unused-vars */ + +/** + * Event name constants emitted by AuditService. + */ +export const AUDIT_EVENT_TYPES = { + CREATED: "audit.log.created", +} as const; + +/** + * Supported event types. + */ +export type AuditEventType = (typeof AUDIT_EVENT_TYPES)[keyof typeof AUDIT_EVENT_TYPES]; + +/** + * Payload for audit streaming events. + * + * Includes the full persisted audit log entry and metadata useful for + * downstream subscribers. + */ +export interface AuditEvent { + /** Event type name */ + type: AuditEventType; + + /** Event creation timestamp */ + emittedAt: Date; + + /** Persisted audit log entity */ + payload: AuditLog; +} + +/** + * Port for publishing audit events to an event stream. + */ +export interface IAuditEventPublisher { + /** + * Publishes an audit event to the configured stream/broker. + * + * @param event - The event to publish + */ + publish(_event: AuditEvent): Promise | void; +} diff --git a/src/core/ports/index.ts b/src/core/ports/index.ts index 4284210..637623a 100644 --- a/src/core/ports/index.ts +++ b/src/core/ports/index.ts @@ -73,3 +73,14 @@ export { type AuditObserverEvent, type AuditOperationType, } from "./audit-observer.port"; + +// ============================================================================ +// AUDIT EVENT PUBLISHER PORT - Event Streaming Hooks +// ============================================================================ + +export { + AUDIT_EVENT_TYPES, + type IAuditEventPublisher, + type AuditEvent, + type AuditEventType, +} from "./audit-event-publisher.port"; diff --git a/src/infra/providers/events/event-emitter-audit-event.publisher.ts b/src/infra/providers/events/event-emitter-audit-event.publisher.ts new file mode 100644 index 0000000..ced057c --- /dev/null +++ b/src/infra/providers/events/event-emitter-audit-event.publisher.ts @@ -0,0 +1,51 @@ +/** + * ============================================================================ + * EVENT EMITTER AUDIT EVENT PUBLISHER + * ============================================================================ + * + * Default in-process event streaming adapter using Node.js EventEmitter. + * + * Useful for: + * - Local integrations without external broker + * - Testing event-stream behavior + * - Bridging to app-level subscribers that forward to Kafka/RabbitMQ + * + * @packageDocumentation + */ + +import { EventEmitter } from "node:events"; + +import type { + AuditEvent, + IAuditEventPublisher, +} from "../../../core/ports/audit-event-publisher.port"; + +/** + * EventEmitter-based implementation of IAuditEventPublisher. + */ +export class EventEmitterAuditEventPublisher implements IAuditEventPublisher { + private readonly emitter: EventEmitter; + + /** + * Creates a new publisher. + * + * @param emitter - EventEmitter instance (shared app bus or dedicated one) + */ + constructor(emitter: EventEmitter = new EventEmitter()) { + this.emitter = emitter; + } + + /** + * Publishes an audit event on the emitter channel named by event.type. + */ + publish(event: AuditEvent): void { + this.emitter.emit(event.type, event); + } + + /** + * Exposes the emitter for consumers that want to subscribe in-process. + */ + getEmitter(): EventEmitter { + return this.emitter; + } +} diff --git a/src/infra/providers/events/index.ts b/src/infra/providers/events/index.ts new file mode 100644 index 0000000..5d6e4ff --- /dev/null +++ b/src/infra/providers/events/index.ts @@ -0,0 +1 @@ +export { EventEmitterAuditEventPublisher } from "./event-emitter-audit-event.publisher"; diff --git a/src/infra/providers/index.ts b/src/infra/providers/index.ts index de578bd..1320fe9 100644 --- a/src/infra/providers/index.ts +++ b/src/infra/providers/index.ts @@ -16,3 +16,4 @@ export * from "./id-generator"; export * from "./timestamp"; export * from "./change-detector"; +export * from "./events"; diff --git a/src/nest/interfaces.ts b/src/nest/interfaces.ts index 4c06341..14330f6 100644 --- a/src/nest/interfaces.ts +++ b/src/nest/interfaces.ts @@ -15,6 +15,7 @@ import type { ModuleMetadata, Type } from "@nestjs/common"; import type { Model } from "mongoose"; +import type { IAuditEventPublisher } from "../core/ports/audit-event-publisher.port"; import type { IAuditObserver } from "../core/ports/audit-observer.port"; import type { AuditLog } from "../core/types"; import type { AuditLogDocument } from "../infra/repositories/mongodb/audit-log.schema"; @@ -182,6 +183,20 @@ export interface RetentionConfig { archiveHandler?: ArchiveHandler; } +/** + * Event streaming configuration. + */ +export interface EventStreamingConfig { + /** Enable/disable audit event emission. */ + enabled?: boolean; + + /** + * Optional event publisher adapter. + * When omitted and enabled=true, a default EventEmitter publisher is used. + */ + publisher?: IAuditEventPublisher; +} + // ============================================================================ // MAIN MODULE OPTIONS // ============================================================================ @@ -288,6 +303,11 @@ export interface AuditKitModuleOptions { * ``` */ observer?: IAuditObserver; + + /** + * Event streaming settings for emitting audit lifecycle events. + */ + eventStreaming?: EventStreamingConfig; } // ============================================================================ diff --git a/src/nest/module.spec.ts b/src/nest/module.spec.ts index 76e7481..03e23e9 100644 --- a/src/nest/module.spec.ts +++ b/src/nest/module.spec.ts @@ -508,5 +508,21 @@ describe("AuditKitModule", () => { }), ).toThrow("Retention with archiveBeforeDelete=true requires an archiveHandler"); }); + + it("should throw when event streaming publisher is configured but disabled", async () => { + expect(() => + AuditKitModule.register({ + repository: { type: "in-memory" }, + eventStreaming: { + enabled: false, + publisher: { + publish: () => { + // no-op + }, + }, + }, + }), + ).toThrow("Event streaming publisher is configured but event streaming is disabled"); + }); }); }); diff --git a/src/nest/module.ts b/src/nest/module.ts index a000afb..fbd1aef 100644 --- a/src/nest/module.ts +++ b/src/nest/module.ts @@ -28,6 +28,7 @@ import type { IChangeDetector } from "../core/ports/change-detector.port"; import type { IIdGenerator } from "../core/ports/id-generator.port"; import type { ITimestampProvider } from "../core/ports/timestamp-provider.port"; import { DeepDiffChangeDetector } from "../infra/providers/change-detector/deep-diff-change-detector"; +import { EventEmitterAuditEventPublisher } from "../infra/providers/events/event-emitter-audit-event.publisher"; import { NanoidIdGenerator } from "../infra/providers/id-generator/nanoid-id-generator"; import { SystemTimestampProvider } from "../infra/providers/timestamp/system-timestamp-provider"; import { InMemoryAuditRepository } from "../infra/repositories/in-memory/in-memory-audit.repository"; @@ -370,12 +371,17 @@ export class AuditKitModule { changeDetector: IChangeDetector, moduleOptions: AuditKitModuleOptions, ) => { + const runtimeOptions = toAuditServiceRuntimeOptions(moduleOptions); + if (moduleOptions.eventStreaming?.enabled && !runtimeOptions.eventPublisher) { + runtimeOptions.eventPublisher = new EventEmitterAuditEventPublisher(); + } + return new AuditService( repository, idGenerator, timestampProvider, changeDetector, - toAuditServiceRuntimeOptions(moduleOptions), + runtimeOptions, ); }, inject: [ diff --git a/src/nest/options.validation.ts b/src/nest/options.validation.ts index ea67a9c..c27ac05 100644 --- a/src/nest/options.validation.ts +++ b/src/nest/options.validation.ts @@ -8,6 +8,7 @@ * @packageDocumentation */ +import type { IAuditEventPublisher } from "../core/ports/audit-event-publisher.port"; import type { IAuditObserver } from "../core/ports/audit-observer.port"; import type { AuditKitModuleOptions } from "./interfaces"; @@ -33,6 +34,9 @@ export interface AuditServiceRuntimeOptions { }; /** Observability observer wired from module options. */ observer?: IAuditObserver; + + /** Event publisher wired from module options. */ + eventPublisher?: IAuditEventPublisher; } /** @@ -70,6 +74,10 @@ export function validateAuditKitModuleOptions(options: AuditKitModuleOptions): v if (options.idempotency?.keyStrategy === "requestId" && options.idempotency.enabled === false) { throw new Error("Idempotency key strategy is configured but idempotency is disabled"); } + + if (options.eventStreaming?.enabled === false && options.eventStreaming.publisher) { + throw new Error("Event streaming publisher is configured but event streaming is disabled"); + } } /** @@ -107,6 +115,10 @@ export function toAuditServiceRuntimeOptions( runtimeOptions.observer = options.observer; } + if (options.eventStreaming?.enabled && options.eventStreaming.publisher) { + runtimeOptions.eventPublisher = options.eventStreaming.publisher; + } + return runtimeOptions; } diff --git a/src/nest/providers.ts b/src/nest/providers.ts index 3ec0249..2bd59d1 100644 --- a/src/nest/providers.ts +++ b/src/nest/providers.ts @@ -23,6 +23,7 @@ import type { IChangeDetector } from "../core/ports/change-detector.port"; import type { IIdGenerator } from "../core/ports/id-generator.port"; import type { ITimestampProvider } from "../core/ports/timestamp-provider.port"; import { DeepDiffChangeDetector } from "../infra/providers/change-detector/deep-diff-change-detector"; +import { EventEmitterAuditEventPublisher } from "../infra/providers/events/event-emitter-audit-event.publisher"; import { NanoidIdGenerator } from "../infra/providers/id-generator/nanoid-id-generator"; import { SystemTimestampProvider } from "../infra/providers/timestamp/system-timestamp-provider"; import { InMemoryAuditRepository } from "../infra/repositories/in-memory/in-memory-audit.repository"; @@ -188,12 +189,17 @@ export function createAuditKitProviders(options: AuditKitModuleOptions): Provide timestampProvider: ITimestampProvider, changeDetector: IChangeDetector, ) => { + const runtimeOptions = toAuditServiceRuntimeOptions(options); + if (options.eventStreaming?.enabled && !runtimeOptions.eventPublisher) { + runtimeOptions.eventPublisher = new EventEmitterAuditEventPublisher(); + } + return new AuditService( repository, idGenerator, timestampProvider, changeDetector, - toAuditServiceRuntimeOptions(options), + runtimeOptions, ); }, inject: [AUDIT_REPOSITORY, ID_GENERATOR, TIMESTAMP_PROVIDER, CHANGE_DETECTOR], From 27c86949a99a968880c8e886d0ddd6b3643033c5 Mon Sep 17 00:00:00 2001 From: yasser Date: Thu, 26 Mar 2026 17:55:38 +0100 Subject: [PATCH 5/8] style: enforce LF line endings and add .gitattributes --- .gitattributes | 14 ++++++++++++++ .prettierrc | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c1d3ccd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +# Enforce LF line endings for all text files on all platforms +* text=auto eol=lf + +# Explicitly declare binary files to prevent corruption +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.woff binary +*.woff2 binary +*.ttf binary +*.otf binary +*.eot binary diff --git a/.prettierrc b/.prettierrc index bccee91..f504493 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,5 +2,6 @@ "semi": true, "singleQuote": false, "trailingComma": "all", - "printWidth": 100 + "printWidth": 100, + "endOfLine": "lf" } From 8a60f9c01a1e5636184012bb113b26ac88636fd7 Mon Sep 17 00:00:00 2001 From: yasser Date: Thu, 26 Mar 2026 18:08:58 +0100 Subject: [PATCH 6/8] fix: resolve SonarCloud quality gate failures and warnings --- benchmarks/audit-service.bench.ts | 4 +- src/core/audit.service.ts | 44 ++++++------- .../in-memory/in-memory-audit.repository.ts | 61 +++++++++++-------- .../mongodb/mongo-audit.repository.ts | 49 +++++++++------ src/nest/options.validation.ts | 56 +++++++++++------ 5 files changed, 124 insertions(+), 90 deletions(-) diff --git a/benchmarks/audit-service.bench.ts b/benchmarks/audit-service.bench.ts index a90df5b..9595b52 100644 --- a/benchmarks/audit-service.bench.ts +++ b/benchmarks/audit-service.bench.ts @@ -129,7 +129,7 @@ describe("AuditService.log()", () => { resource: benchResource, metadata: { reason: "GDPR request", requestId: "req-123" }, reason: "User data export", - ipAddress: "192.168.1.1", + ipAddress: "127.0.0.1", }); }); }); @@ -221,7 +221,7 @@ describe("AuditService.queryWithCursor() — cursor pagination", () => { bench("queryWithCursor() — second page (using cursor)", async () => { await service.queryWithCursor( {}, - { limit: 20, ...(nextCursor !== undefined ? { cursor: nextCursor } : {}) }, + { limit: 20, ...(nextCursor === undefined ? {} : { cursor: nextCursor }) }, ); }); }); diff --git a/src/core/audit.service.ts b/src/core/audit.service.ts index b07e211..f00fe7c 100644 --- a/src/core/audit.service.ts +++ b/src/core/audit.service.ts @@ -263,30 +263,7 @@ export class AuditService { }; // Add optional fields only if they're defined - if (dto.changes !== undefined) { - (auditLog as any).changes = dto.changes; - } - if (dto.metadata !== undefined) { - (auditLog as any).metadata = dto.metadata; - } - if (dto.ipAddress !== undefined) { - (auditLog as any).ipAddress = dto.ipAddress; - } - if (dto.userAgent !== undefined) { - (auditLog as any).userAgent = dto.userAgent; - } - if (dto.requestId !== undefined) { - (auditLog as any).requestId = dto.requestId; - } - if (dto.sessionId !== undefined) { - (auditLog as any).sessionId = dto.sessionId; - } - if (dto.idempotencyKey !== undefined) { - (auditLog as any).idempotencyKey = dto.idempotencyKey; - } - if (dto.reason !== undefined) { - (auditLog as any).reason = dto.reason; - } + this.assignOptionalFields(auditLog, dto); const logToPersist = this.applyPiiRedaction(auditLog); @@ -722,6 +699,21 @@ export class AuditService { // VALIDATION - Business Rule Enforcement // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + /** + * Assigns optional fields from a DTO onto an AuditLog object. + * Extracted to keep `log()` cognitive complexity within acceptable bounds. + */ + private assignOptionalFields(auditLog: AuditLog, dto: CreateAuditLogDto): void { + if (dto.changes !== undefined) (auditLog as any).changes = dto.changes; + if (dto.metadata !== undefined) (auditLog as any).metadata = dto.metadata; + if (dto.ipAddress !== undefined) (auditLog as any).ipAddress = dto.ipAddress; + if (dto.userAgent !== undefined) (auditLog as any).userAgent = dto.userAgent; + if (dto.requestId !== undefined) (auditLog as any).requestId = dto.requestId; + if (dto.sessionId !== undefined) (auditLog as any).sessionId = dto.sessionId; + if (dto.idempotencyKey !== undefined) (auditLog as any).idempotencyKey = dto.idempotencyKey; + if (dto.reason !== undefined) (auditLog as any).reason = dto.reason; + } + /** * Validates an actor (ensures all required fields are present and valid). * @@ -856,7 +848,7 @@ export class AuditService { cursor = next as Record; } - const leaf = segments[segments.length - 1]; + const leaf = segments.at(-1); if (!leaf) { return; } @@ -871,7 +863,7 @@ export class AuditService { */ private deepClone(value: T): T { if (value instanceof Date) { - return new Date(value.getTime()) as T; + return new Date(value) as T; } if (Array.isArray(value)) { diff --git a/src/infra/repositories/in-memory/in-memory-audit.repository.ts b/src/infra/repositories/in-memory/in-memory-audit.repository.ts index f8fae91..438993e 100644 --- a/src/infra/repositories/in-memory/in-memory-audit.repository.ts +++ b/src/infra/repositories/in-memory/in-memory-audit.repository.ts @@ -340,7 +340,7 @@ export class InMemoryAuditRepository implements IAuditLogRepository { const page = sorted.slice(0, limit); const data = page.map((log) => this.deepCopy(log)); - const lastItem = data[data.length - 1]; + const lastItem = data.at(-1); const result: CursorPageResult = { data, hasMore, @@ -390,47 +390,60 @@ export class InMemoryAuditRepository implements IAuditLogRepository { * @returns True if log matches all filters */ private matchesFilters(log: AuditLog, filters: Partial): boolean { - // Actor filters + return ( + this.matchesActorFilters(log, filters) && + this.matchesResourceFilters(log, filters) && + this.matchesDateAndActionFilters(log, filters) && + this.matchesMetadataFilters(log, filters) && + this.matchesSearchFilter(log, filters) + ); + } + + private matchesActorFilters(log: AuditLog, filters: Partial): boolean { if (filters.actorId && log.actor.id !== filters.actorId) return false; if (filters.actorType && log.actor.type !== filters.actorType) return false; + return true; + } - // Resource filters + private matchesResourceFilters(log: AuditLog, filters: Partial): boolean { if (filters.resourceType && log.resource.type !== filters.resourceType) return false; if (filters.resourceId && log.resource.id !== filters.resourceId) return false; + return true; + } - // Action filter + private matchesDateAndActionFilters(log: AuditLog, filters: Partial): boolean { if (filters.action && log.action !== filters.action) return false; if (filters.actions && !filters.actions.includes(log.action)) return false; - - // Date range if (filters.startDate && log.timestamp < filters.startDate) return false; if (filters.endDate && log.timestamp > filters.endDate) return false; + return true; + } - // Other filters + private matchesMetadataFilters(log: AuditLog, filters: Partial): boolean { if (filters.ipAddress && log.ipAddress !== filters.ipAddress) return false; if (filters.requestId && log.requestId !== filters.requestId) return false; if (filters.sessionId && log.sessionId !== filters.sessionId) return false; if (filters.idempotencyKey && log.idempotencyKey !== filters.idempotencyKey) return false; - - // Simple text search (searches in action, resource type, actor name) - if (filters.search) { - const searchLower = filters.search.toLowerCase(); - const searchableText = [ - log.action, - log.resource.type, - log.actor.name || "", - log.actionDescription || "", - log.reason || "", - ] - .join(" ") - .toLowerCase(); - - if (!searchableText.includes(searchLower)) return false; - } - return true; } + private matchesSearchFilter(log: AuditLog, filters: Partial): boolean { + if (!filters.search) return true; + + const searchLower = filters.search.toLowerCase(); + const searchableText = [ + log.action, + log.resource.type, + log.actor.name ?? "", + log.actionDescription ?? "", + log.reason ?? "", + ] + .join(" ") + .toLowerCase(); + + return searchableText.includes(searchLower); + } + /** * Sorts audit logs by timestamp. * diff --git a/src/infra/repositories/mongodb/mongo-audit.repository.ts b/src/infra/repositories/mongodb/mongo-audit.repository.ts index fefceab..f662dcf 100644 --- a/src/infra/repositories/mongodb/mongo-audit.repository.ts +++ b/src/infra/repositories/mongodb/mongo-audit.repository.ts @@ -294,7 +294,7 @@ export class MongoAuditRepository implements IAuditLogRepository { const pageDocuments = documents.slice(0, limit); const data = pageDocuments.map((doc) => this.toPlainObject(doc)); - const lastItem = data[data.length - 1]; + const lastItem = data.at(-1); const result: CursorPageResult = { data, hasMore, @@ -323,41 +323,54 @@ export class MongoAuditRepository implements IAuditLogRepository { */ private buildQuery(filters: Partial): Record { const query: Record = {}; + this.applyActorFilters(query, filters); + this.applyResourceFilters(query, filters); + this.applyActionFilter(query, filters); + this.applyDateRangeFilter(query, filters); + this.applyMetadataFilters(query, filters); + if (filters.search) query.$text = { $search: filters.search }; + return query; + } - // Actor filters + private applyActorFilters(query: Record, filters: Partial): void { if (filters.actorId) query["actor.id"] = filters.actorId; if (filters.actorType) query["actor.type"] = filters.actorType; + } - // Resource filters + private applyResourceFilters( + query: Record, + filters: Partial, + ): void { if (filters.resourceType) query["resource.type"] = filters.resourceType; if (filters.resourceId) query["resource.id"] = filters.resourceId; + } - // Action filter (can be single action or array) + private applyActionFilter(query: Record, filters: Partial): void { if (filters.action) { query.action = filters.action; } else if (filters.actions && filters.actions.length > 0) { query.action = { $in: filters.actions }; } + } - // Date range filters - if (filters.startDate || filters.endDate) { - query.timestamp = {}; - if (filters.startDate) query.timestamp.$gte = filters.startDate; - if (filters.endDate) query.timestamp.$lte = filters.endDate; - } + private applyDateRangeFilter( + query: Record, + filters: Partial, + ): void { + if (!filters.startDate && !filters.endDate) return; + query.timestamp = {}; + if (filters.startDate) query.timestamp.$gte = filters.startDate; + if (filters.endDate) query.timestamp.$lte = filters.endDate; + } - // Other filters + private applyMetadataFilters( + query: Record, + filters: Partial, + ): void { if (filters.ipAddress) query.ipAddress = filters.ipAddress; if (filters.requestId) query.requestId = filters.requestId; if (filters.sessionId) query.sessionId = filters.sessionId; if (filters.idempotencyKey) query.idempotencyKey = filters.idempotencyKey; - - // Full-text search (if text index is configured) - if (filters.search) { - query.$text = { $search: filters.search }; - } - - return query; } /** diff --git a/src/nest/options.validation.ts b/src/nest/options.validation.ts index c27ac05..f8f47c2 100644 --- a/src/nest/options.validation.ts +++ b/src/nest/options.validation.ts @@ -43,39 +43,55 @@ export interface AuditServiceRuntimeOptions { * Validates module options and throws a descriptive Error on invalid configuration. */ export function validateAuditKitModuleOptions(options: AuditKitModuleOptions): void { - if (!options || !options.repository) { - throw new Error("AuditKitModule options must include a repository configuration"); - } + validateRepository(options); + validateRedaction(options); + validateRetention(options); + validateIdempotency(options); + validateEventStreaming(options); +} - if (options.repository.type === "mongodb") { - if (!options.repository.uri && !options.repository.model) { - throw new Error("MongoDB repository requires either 'uri' or 'model' to be configured"); - } +function validateRepository(options: AuditKitModuleOptions): void { + if (!options?.repository) { + throw new Error("AuditKitModule options must include a repository configuration"); } if ( - options.redaction?.fields && - options.redaction.fields.some((field) => typeof field !== "string" || field.trim().length === 0) + options.repository.type === "mongodb" && + !options.repository.uri && + !options.repository.model ) { + throw new Error("MongoDB repository requires either 'uri' or 'model' to be configured"); + } +} + +function validateRedaction(options: AuditKitModuleOptions): void { + const fields = options.redaction?.fields; + if (fields && fields.some((field) => typeof field !== "string" || field.trim().length === 0)) { throw new Error("Redaction fields must be non-empty strings"); } +} - if (options.retention?.enabled) { - const retentionDays = options.retention.retentionDays; - if (!Number.isInteger(retentionDays) || (retentionDays as number) <= 0) { - throw new Error("Retention requires a positive integer 'retentionDays'"); - } +function validateRetention(options: AuditKitModuleOptions): void { + if (!options.retention?.enabled) return; - if (options.retention.archiveBeforeDelete && options.retention.archiveHandler === undefined) { - throw new Error("Retention with archiveBeforeDelete=true requires an archiveHandler"); - } + const { retentionDays, archiveBeforeDelete, archiveHandler } = options.retention; + if (!Number.isInteger(retentionDays) || (retentionDays as number) <= 0) { + throw new Error("Retention requires a positive integer 'retentionDays'"); } - if (options.idempotency?.keyStrategy === "requestId" && options.idempotency.enabled === false) { + if (archiveBeforeDelete && archiveHandler === undefined) { + throw new Error("Retention with archiveBeforeDelete=true requires an archiveHandler"); + } +} + +function validateIdempotency(options: AuditKitModuleOptions): void { + if (options.idempotency?.keyStrategy === "requestId" && options.idempotency?.enabled === false) { throw new Error("Idempotency key strategy is configured but idempotency is disabled"); } +} - if (options.eventStreaming?.enabled === false && options.eventStreaming.publisher) { +function validateEventStreaming(options: AuditKitModuleOptions): void { + if (options.eventStreaming?.enabled === false && options.eventStreaming?.publisher) { throw new Error("Event streaming publisher is configured but event streaming is disabled"); } } @@ -115,7 +131,7 @@ export function toAuditServiceRuntimeOptions( runtimeOptions.observer = options.observer; } - if (options.eventStreaming?.enabled && options.eventStreaming.publisher) { + if (options.eventStreaming?.enabled && options.eventStreaming?.publisher) { runtimeOptions.eventPublisher = options.eventStreaming.publisher; } From 2a7c1d814cc987519c235fa8ea4a64f26d0a1c5d Mon Sep 17 00:00:00 2001 From: yasser Date: Fri, 27 Mar 2026 08:56:20 +0100 Subject: [PATCH 7/8] fix: update @nestjs/common to 11.1.17 to patch file-type CVEs --- package-lock.json | 84 ++++++++++++++++++++++++++--------------------- package.json | 3 ++ 2 files changed, 49 insertions(+), 38 deletions(-) diff --git a/package-lock.json b/package-lock.json index 629a934..f1c042c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ }, "devDependencies": { "@changesets/cli": "^2.27.7", + "@nestjs/common": "^11.1.17", + "@nestjs/core": "^11.1.17", "@nestjs/testing": "^11.1.17", "@stryker-mutator/core": "^9.0.0", "@stryker-mutator/jest-runner": "^9.0.0", @@ -28,6 +30,7 @@ "mongoose": "^8.11.3", "nanoid": "^5.0.9", "prettier": "^3.4.2", + "reflect-metadata": "^0.2.2", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "tsup": "^8.3.5", @@ -819,11 +822,11 @@ "license": "MIT" }, "node_modules/@borewit/text-codec": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz", - "integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "dev": true, "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/Borewit" @@ -2875,8 +2878,8 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -3060,13 +3063,13 @@ } }, "node_modules/@nestjs/common": { - "version": "11.1.14", - "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.14.tgz", - "integrity": "sha512-IN/tlqd7Nl9gl6f0jsWEuOrQDaCI9vHzxv0fisHysfBQzfQIkqlv5A7w4Qge02BUQyczXT9HHPgHtWHCxhjRng==", + "version": "11.1.17", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.17.tgz", + "integrity": "sha512-hLODw5Abp8OQgA+mUO4tHou4krKgDtUcM9j5Ihxncst9XeyxYBTt2bwZm4e4EQr5E352S4Fyy6V3iFx9ggxKAg==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "file-type": "21.3.0", + "file-type": "21.3.2", "iterare": "1.2.1", "load-esm": "1.0.3", "tslib": "2.8.1", @@ -3092,12 +3095,12 @@ } }, "node_modules/@nestjs/core": { - "version": "11.1.14", - "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.14.tgz", - "integrity": "sha512-7OXPPMoDr6z+5NkoQKu4hOhfjz/YYqM3bNilPqv1WVFWrzSmuNXxvhbX69YMmNmRYascPXiwESqf5jJdjKXEww==", + "version": "11.1.17", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.17.tgz", + "integrity": "sha512-lD5mAYekTTurF3vDaa8C2OKPnjiz4tsfxIc5XlcSUzOhkwWf6Ay3HKvt6FmvuWQam6uIIHX52Clg+e6tAvf/cg==", + "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -3203,8 +3206,8 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/@nuxt/opencollective/-/opencollective-0.4.1.tgz", "integrity": "sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "consola": "^3.2.3" }, @@ -3993,8 +3996,8 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" @@ -4011,8 +4014,8 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/@tsconfig/node10": { "version": "1.0.12", @@ -5599,6 +5602,7 @@ "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" @@ -5724,6 +5728,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -6653,8 +6658,8 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "license": "MIT", - "peer": true + "dev": true, + "license": "MIT" }, "node_modules/fast-uri": { "version": "3.1.0", @@ -6741,11 +6746,11 @@ } }, "node_modules/file-type": { - "version": "21.3.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.0.tgz", - "integrity": "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==", + "version": "21.3.2", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.2.tgz", + "integrity": "sha512-DLkUvGwep3poOV2wpzbHCOnSKGk1LzyXTv+aHFgN2VFl96wnp8YA9YjO2qPzg5PuL8q/SW9Pdi6WTkYOIh995w==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", @@ -7311,6 +7316,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, "funding": [ { "type": "github", @@ -7325,8 +7331,7 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/ignore": { "version": "5.3.2", @@ -7999,8 +8004,8 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", + "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">=6" } @@ -8888,6 +8893,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz", "integrity": "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==", + "dev": true, "funding": [ { "type": "github", @@ -8899,7 +8905,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=13.2.0" } @@ -9328,6 +9333,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, "license": "MIT" }, "node_modules/mutation-testing-elements": { @@ -9796,8 +9802,8 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "dev": true, "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/express" @@ -10315,8 +10321,8 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0", - "peer": true + "dev": true, + "license": "Apache-2.0" }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -10570,6 +10576,7 @@ "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" @@ -11132,11 +11139,11 @@ } }, "node_modules/strtok3": { - "version": "10.3.4", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", - "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==", + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@tokenizer/token": "^0.3.0" }, @@ -11355,8 +11362,8 @@ "version": "6.1.2", "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", @@ -11527,6 +11534,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, "license": "0BSD" }, "node_modules/tsup": { @@ -11806,8 +11814,8 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@lukeed/csprng": "^1.0.0" }, @@ -11819,8 +11827,8 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, diff --git a/package.json b/package.json index 4d130d0..838876c 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,8 @@ }, "devDependencies": { "@changesets/cli": "^2.27.7", + "@nestjs/common": "^11.1.17", + "@nestjs/core": "^11.1.17", "@nestjs/testing": "^11.1.17", "@stryker-mutator/core": "^9.0.0", "@stryker-mutator/jest-runner": "^9.0.0", @@ -73,6 +75,7 @@ "mongoose": "^8.11.3", "nanoid": "^5.0.9", "prettier": "^3.4.2", + "reflect-metadata": "^0.2.2", "ts-jest": "^29.2.5", "ts-node": "^10.9.2", "tsup": "^8.3.5", From c6b525532f21be14736e41de1d5c8a019b945369 Mon Sep 17 00:00:00 2001 From: yasser Date: Fri, 27 Mar 2026 11:29:33 +0100 Subject: [PATCH 8/8] refactor: remove MongoDB adapter, add custom repository config --- package-lock.json | 214 --------- package.json | 2 - src/infra/repositories/index.ts | 5 - .../repositories/mongodb/audit-log.schema.ts | 212 --------- src/infra/repositories/mongodb/index.ts | 12 - .../mongodb/mongo-audit.repository.spec.ts | 416 ------------------ .../mongodb/mongo-audit.repository.ts | 416 ------------------ src/nest/interfaces.ts | 59 +-- src/nest/module.spec.ts | 24 +- src/nest/module.ts | 58 +-- src/nest/options.validation.ts | 8 +- src/nest/providers.ts | 31 +- test/smoke.test.ts | 3 - 13 files changed, 57 insertions(+), 1403 deletions(-) delete mode 100644 src/infra/repositories/mongodb/audit-log.schema.ts delete mode 100644 src/infra/repositories/mongodb/index.ts delete mode 100644 src/infra/repositories/mongodb/mongo-audit.repository.spec.ts delete mode 100644 src/infra/repositories/mongodb/mongo-audit.repository.ts delete mode 100644 test/smoke.test.ts diff --git a/package-lock.json b/package-lock.json index f1c042c..dd009ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,6 @@ "husky": "^9.1.7", "jest": "^29.7.0", "lint-staged": "^16.2.7", - "mongoose": "^8.11.3", "nanoid": "^5.0.9", "prettier": "^3.4.2", "reflect-metadata": "^0.2.2", @@ -45,7 +44,6 @@ "@nestjs/common": "^10 || ^11", "@nestjs/core": "^10 || ^11", "date-fns": "^4", - "mongoose": "^8", "nanoid": "^5", "reflect-metadata": "^0.2.2", "rxjs": "^7" @@ -3052,16 +3050,6 @@ "node": ">= 4.0.0" } }, - "node_modules/@mongodb-js/saslprep": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz", - "integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "sparse-bitfield": "^3.0.3" - } - }, "node_modules/@nestjs/common": { "version": "11.1.17", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.17.tgz", @@ -4176,23 +4164,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/webidl-conversions": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", - "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/whatwg-url": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", - "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/webidl-conversions": "*" - } - }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -5173,16 +5144,6 @@ "node-int64": "^0.4.0" } }, - "node_modules/bson": { - "version": "6.10.4", - "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", - "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=16.20.1" - } - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -8762,16 +8723,6 @@ "node": ">=6" } }, - "node_modules/kareem": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", - "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -9098,13 +9049,6 @@ "node": ">= 0.4" } }, - "node_modules/memory-pager": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", - "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", - "dev": true, - "license": "MIT" - }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -9215,110 +9159,6 @@ "ufo": "^1.6.1" } }, - "node_modules/mongodb": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz", - "integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@mongodb-js/saslprep": "^1.3.0", - "bson": "^6.10.4", - "mongodb-connection-string-url": "^3.0.2" - }, - "engines": { - "node": ">=16.20.1" - }, - "peerDependencies": { - "@aws-sdk/credential-providers": "^3.188.0", - "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", - "gcp-metadata": "^5.2.0", - "kerberos": "^2.0.1", - "mongodb-client-encryption": ">=6.0.0 <7", - "snappy": "^7.3.2", - "socks": "^2.7.1" - }, - "peerDependenciesMeta": { - "@aws-sdk/credential-providers": { - "optional": true - }, - "@mongodb-js/zstd": { - "optional": true - }, - "gcp-metadata": { - "optional": true - }, - "kerberos": { - "optional": true - }, - "mongodb-client-encryption": { - "optional": true - }, - "snappy": { - "optional": true - }, - "socks": { - "optional": true - } - } - }, - "node_modules/mongodb-connection-string-url": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", - "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/whatwg-url": "^11.0.2", - "whatwg-url": "^14.1.0 || ^13.0.0" - } - }, - "node_modules/mongoose": { - "version": "8.23.0", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.23.0.tgz", - "integrity": "sha512-Bul4Ha6J8IqzFrb0B1xpVzkC3S0sk43dmLSnhFOn8eJlZiLwL5WO6cRymmjaADdCMjUcCpj2ce8hZI6O4ZFSug==", - "dev": true, - "license": "MIT", - "dependencies": { - "bson": "^6.10.4", - "kareem": "2.6.3", - "mongodb": "~6.20.0", - "mpath": "0.9.0", - "mquery": "5.0.0", - "ms": "2.1.3", - "sift": "17.1.3" - }, - "engines": { - "node": ">=16.20.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mongoose" - } - }, - "node_modules/mpath": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", - "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/mquery": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", - "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "4.x" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -10805,13 +10645,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/sift": { - "version": "17.1.3", - "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", - "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", - "dev": true, - "license": "MIT" - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -10915,16 +10748,6 @@ "node": ">=0.10.0" } }, - "node_modules/sparse-bitfield": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", - "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "memory-pager": "^1.0.2" - } - }, "node_modules/spawndamnit": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/spawndamnit/-/spawndamnit-3.0.1.tgz", @@ -11377,19 +11200,6 @@ "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/tr46": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -12610,30 +12420,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-url": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 838876c..f3ad2f6 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,6 @@ "@nestjs/common": "^10 || ^11", "@nestjs/core": "^10 || ^11", "date-fns": "^4", - "mongoose": "^8", "nanoid": "^5", "reflect-metadata": "^0.2.2", "rxjs": "^7" @@ -72,7 +71,6 @@ "husky": "^9.1.7", "jest": "^29.7.0", "lint-staged": "^16.2.7", - "mongoose": "^8.11.3", "nanoid": "^5.0.9", "prettier": "^3.4.2", "reflect-metadata": "^0.2.2", diff --git a/src/infra/repositories/index.ts b/src/infra/repositories/index.ts index 4f2b7a7..05f2c19 100644 --- a/src/infra/repositories/index.ts +++ b/src/infra/repositories/index.ts @@ -6,14 +6,9 @@ * Exports for all audit repository implementations. * * Available implementations: - * - MongoDB (via Mongoose) * - In-Memory (testing, simple deployments) * * @packageDocumentation */ -// MongoDB implementation -export * from "./mongodb"; - -// In-Memory implementation export * from "./in-memory"; diff --git a/src/infra/repositories/mongodb/audit-log.schema.ts b/src/infra/repositories/mongodb/audit-log.schema.ts deleted file mode 100644 index 502e7df..0000000 --- a/src/infra/repositories/mongodb/audit-log.schema.ts +++ /dev/null @@ -1,212 +0,0 @@ -/** - * ============================================================================ - * MONGOOSE SCHEMA FOR AUDIT LOGS - * ============================================================================ - * - * MongoDB schema definition for audit log persistence. - * - * Purpose: - * - Define MongoDB collection structure - * - Ensure data validation at database level - * - Configure indexes for optimal query performance - * - Enable TypeScript type safety with Mongoose - * - * Schema Design Principles: - * - **Immutable**: No update operations, audit logs never change - * - **Append-only**: Optimized for inserts and reads - * - **Query-optimized**: Indexes on common access patterns - * - **Time-series friendly**: Can use MongoDB time-series collections - * - * @packageDocumentation - */ - -import { Schema, type Document } from "mongoose"; - -import type { AuditLog } from "../../../core/types"; - -/** - * MongoDB document type for AuditLog. - * Extends Mongoose Document for database operations. - */ -export type AuditLogDocument = AuditLog & Document; - -/** - * Actor sub-schema (actor information). - * Embedded document for who performed the action. - */ -const ActorSchema = new Schema( - { - id: { type: String, required: true, index: true }, - type: { - type: String, - required: true, - enum: ["user", "system", "service"], - index: true, - }, - name: { type: String }, - email: { type: String }, - metadata: { type: Schema.Types.Mixed }, - }, - { _id: false }, -); - -/** - * Resource sub-schema (what was acted upon). - * Embedded document for the target of the action. - */ -const ResourceSchema = new Schema( - { - type: { type: String, required: true, index: true }, - id: { type: String, required: true, index: true }, - label: { type: String }, - metadata: { type: Schema.Types.Mixed }, - }, - { _id: false }, -); - -/** - * Main AuditLog schema. - * - * Indexes: - * - id: Primary key, unique identifier - * - timestamp: Time-series queries, retention policies - * - actor.id: "What did this user do?" - * - actor.type: "All system actions" - * - resource.type + resource.id: "Complete resource history" - * - action: "All DELETE actions" - * - ipAddress: Security investigations - * - requestId: Distributed tracing - * - * Compound indexes for common query patterns: - * - {timestamp: -1}: Newest-first sorting (most common) - * - {actor.id: 1, timestamp: -1}: User activity timeline - * - {resource.type: 1, resource.id: 1, timestamp: 1}: Resource history chronologically - */ -export const AuditLogSchema = new Schema( - { - id: { - type: String, - required: true, - unique: true, - index: true, - }, - timestamp: { - type: Date, - required: true, - index: true, - }, - actor: { - type: ActorSchema, - required: true, - }, - action: { - type: String, - required: true, - index: true, - }, - actionDescription: { - type: String, - }, - resource: { - type: ResourceSchema, - required: true, - }, - changes: { - type: Schema.Types.Mixed, - }, - metadata: { - type: Schema.Types.Mixed, - }, - ipAddress: { - type: String, - index: true, - }, - userAgent: { - type: String, - }, - requestId: { - type: String, - }, - sessionId: { - type: String, - index: true, - }, - idempotencyKey: { - type: String, - sparse: true, - }, - reason: { - type: String, - }, - }, - { - collection: "audit_logs", - timestamps: false, // We manage timestamp ourselves - versionKey: false, // Audit logs are immutable, no versioning needed - }, -); - -/** - * Compound indexes for optimized query patterns. - * These support the most common access patterns from IAuditLogRepository. - */ - -// Timeline queries: newest first (default sorting in most UIs) -AuditLogSchema.index({ timestamp: -1 }); - -// User activity timeline -AuditLogSchema.index({ "actor.id": 1, timestamp: -1 }); - -// Resource history (chronological order for complete story) -AuditLogSchema.index({ "resource.type": 1, "resource.id": 1, timestamp: 1 }); - -// Action-based queries with time filtering -AuditLogSchema.index({ action: 1, timestamp: -1 }); - -// Security investigations by IP -AuditLogSchema.index({ ipAddress: 1, timestamp: -1 }); - -// Distributed tracing -AuditLogSchema.index({ requestId: 1 }); - -/** - * Schema options for production use. - * - * Consider enabling: - * - Time-series collection (MongoDB 5.0+) for better performance - * - Capped collection for automatic old data removal - * - Expiration via TTL index for retention policies - */ - -// Example: TTL index for automatic deletion after 7 years -// Uncomment if you want automatic expiration: -// AuditLogSchema.index({ timestamp: 1 }, { expireAfterSeconds: 220752000 }); // 7 years - -/** - * Prevents modification of audit logs after creation. - * MongoDB middleware to enforce immutability. - */ -AuditLogSchema.pre("save", function (next) { - // Allow only new documents (inserts) - if (!this.isNew) { - return next(new Error("Audit logs are immutable and cannot be modified")); - } - next(); -}); - -// Prevent updates and deletes -AuditLogSchema.pre("updateOne", function (next) { - next(new Error("Audit logs cannot be updated")); -}); - -AuditLogSchema.pre("findOneAndUpdate", function (next) { - next(new Error("Audit logs cannot be updated")); -}); - -AuditLogSchema.pre("deleteOne", function (next) { - next(new Error("Audit logs cannot be deleted (append-only)")); -}); - -AuditLogSchema.pre("findOneAndDelete", function (next) { - next(new Error("Audit logs cannot be deleted (append-only)")); -}); diff --git a/src/infra/repositories/mongodb/index.ts b/src/infra/repositories/mongodb/index.ts deleted file mode 100644 index 2336e50..0000000 --- a/src/infra/repositories/mongodb/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * ============================================================================ - * MONGODB REPOSITORY - PUBLIC EXPORTS - * ============================================================================ - * - * Exports for MongoDB audit repository implementation. - * - * @packageDocumentation - */ - -export { AuditLogSchema, type AuditLogDocument } from "./audit-log.schema"; -export { MongoAuditRepository } from "./mongo-audit.repository"; diff --git a/src/infra/repositories/mongodb/mongo-audit.repository.spec.ts b/src/infra/repositories/mongodb/mongo-audit.repository.spec.ts deleted file mode 100644 index 4dbca15..0000000 --- a/src/infra/repositories/mongodb/mongo-audit.repository.spec.ts +++ /dev/null @@ -1,416 +0,0 @@ -/** - * ============================================================================ - * MONGODB AUDIT REPOSITORY - UNIT TESTS - * ============================================================================ - * - * Tests for MongoAuditRepository implementation using proper Mongoose mocking. - * - * Coverage: - * - CRUD operations (create, findById) - * - Query operations (findByActor, findByResource, query) - * - Count and exists operations - * - Filtering (by action, actor, resource, date range) - * - Pagination and sorting - * - Document transformation (_id to id mapping) - * - Error handling - * - * @packageDocumentation - */ - -import type { AuditLog } from "../../../core/types"; -import { ActorType, AuditActionType } from "../../../core/types"; - -import { MongoAuditRepository } from "./mongo-audit.repository"; - -describe("MongoAuditRepository", () => { - let repository: MongoAuditRepository; - let mockModel: any; - - const createMockLog = (overrides?: Partial): AuditLog => ({ - id: "log-1", - timestamp: new Date("2026-03-19T10:00:00.000Z"), - action: AuditActionType.CREATE, - actor: { - id: "user-1", - type: ActorType.USER, - name: "John Doe", - email: "john@example.com", - }, - resource: { - type: ActorType.USER, - id: "res-1", - label: "Test User", - }, - ipAddress: "192.0.2.1", - userAgent: "Mozilla/5.0", - ...overrides, - }); - - const setupCreateModelMock = (log: AuditLog) => { - const saveMock = jest.fn().mockResolvedValue({ _id: log.id, ...log }); - mockModel.mockImplementation((data: any) => ({ - ...data, - save: saveMock, - })); - return saveMock; - }; - - const createLeanExecChain = (result: any) => ({ - lean: jest.fn().mockReturnThis(), - exec: jest.fn().mockResolvedValue(result), - }); - - const createSortedLeanExecChain = (result: any) => ({ - sort: jest.fn().mockReturnThis(), - lean: jest.fn().mockReturnThis(), - exec: jest.fn().mockResolvedValue(result), - }); - - const createQueryChain = (result: any) => ({ - sort: jest.fn().mockReturnThis(), - skip: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - lean: jest.fn().mockReturnThis(), - exec: jest.fn().mockResolvedValue(result), - }); - - const createExecChain = (result: any) => ({ - exec: jest.fn().mockResolvedValue(result), - }); - - beforeEach(() => { - // Create a mock Mongoose model with constructor behavior - mockModel = jest.fn().mockImplementation((data: any) => ({ - ...data, - save: jest.fn().mockResolvedValue({ _id: data.id, ...data }), - })); - - // Add static methods to the mock model - mockModel.findOne = jest.fn(); - mockModel.find = jest.fn(); - mockModel.countDocuments = jest.fn(); - mockModel.deleteMany = jest.fn(); - - repository = new MongoAuditRepository(mockModel); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe("create", () => { - it("should create and return audit log", async () => { - const log = createMockLog(); - const saveMock = setupCreateModelMock(log); - - const created = await repository.create(log); - - expect(mockModel).toHaveBeenCalledWith(log); - expect(saveMock).toHaveBeenCalled(); - expect(created.id).toBe(log.id); - expect(created.action).toBe(log.action); - }); - - it("should create log with changes", async () => { - const log = createMockLog({ - changes: { - name: { from: "Old", to: "New" }, - }, - }); - setupCreateModelMock(log); - - await repository.create(log); - - expect(mockModel).toHaveBeenCalledWith( - expect.objectContaining({ - changes: log.changes, - }), - ); - }); - - it("should create log with metadata", async () => { - const log = createMockLog({ - metadata: { correlationId: "corr-1" }, - }); - setupCreateModelMock(log); - - await repository.create(log); - - expect(mockModel).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: log.metadata, - }), - ); - }); - }); - - describe("findById", () => { - it("should return log when it exists", async () => { - const log = createMockLog(); - const chainMock = createLeanExecChain({ _id: log.id, ...log }); - mockModel.findOne.mockReturnValue(chainMock); - - const found = await repository.findById(log.id); - - expect(mockModel.findOne).toHaveBeenCalledWith({ id: log.id }); - expect(chainMock.lean).toHaveBeenCalled(); - expect(chainMock.exec).toHaveBeenCalled(); - expect(found).toMatchObject({ - id: log.id, - action: log.action, - }); - }); - - it("should return null when log does not exist", async () => { - const chainMock = createLeanExecChain(null); - mockModel.findOne.mockReturnValue(chainMock); - - const found = await repository.findById("non-existent"); - - expect(found).toBeNull(); - }); - - it("should transform _id to id", async () => { - const log = createMockLog(); - const chainMock = createLeanExecChain({ _id: log.id, ...log }); - mockModel.findOne.mockReturnValue(chainMock); - - const found = await repository.findById(log.id); - - expect(found).toHaveProperty("id"); - expect(found).not.toHaveProperty("_id"); - }); - }); - - describe("findByActor", () => { - it("should query by actor ID", async () => { - const log = createMockLog(); - const chainMock = createSortedLeanExecChain([{ _id: log.id, ...log }]); - mockModel.find.mockReturnValue(chainMock); - - await repository.findByActor("user-1"); - - expect(mockModel.find).toHaveBeenCalledWith( - expect.objectContaining({ - "actor.id": "user-1", - }), - ); - expect(chainMock.sort).toHaveBeenCalledWith({ timestamp: -1 }); - }); - - it("should apply action filter", async () => { - const chainMock = createSortedLeanExecChain([]); - mockModel.find.mockReturnValue(chainMock); - - await repository.findByActor("user-1", { action: AuditActionType.CREATE }); - - expect(mockModel.find).toHaveBeenCalledWith( - expect.objectContaining({ - "actor.id": "user-1", - action: AuditActionType.CREATE, - }), - ); - }); - }); - - describe("findByResource", () => { - it("should query by resource type and ID", async () => { - const log = createMockLog(); - const chainMock = createSortedLeanExecChain([{ _id: log.id, ...log }]); - mockModel.find.mockReturnValue(chainMock); - - await repository.findByResource("user", "res-1"); - - expect(mockModel.find).toHaveBeenCalledWith( - expect.objectContaining({ - "resource.type": "user", - "resource.id": "res-1", - }), - ); - expect(chainMock.sort).toHaveBeenCalledWith({ timestamp: 1 }); - }); - }); - - describe("query", () => { - it("should build query without filters", async () => { - const chainMock = createQueryChain([]); - mockModel.find.mockReturnValue(chainMock); - - const countChainMock = createExecChain(0); - mockModel.countDocuments.mockReturnValue(countChainMock); - - await repository.query({}); - - expect(mockModel.find).toHaveBeenCalledWith({}); - expect(chainMock.sort).toHaveBeenCalled(); - expect(chainMock.limit).toHaveBeenCalledWith(20); // default limit is 20 - }); - - it("should filter by action", async () => { - const chainMock = createQueryChain([]); - mockModel.find.mockReturnValue(chainMock); - - const countChainMock = createExecChain(0); - mockModel.countDocuments.mockReturnValue(countChainMock); - - await repository.query({ action: AuditActionType.CREATE }); - - expect(mockModel.find).toHaveBeenCalledWith({ action: AuditActionType.CREATE }); - }); - - it("should apply pagination", async () => { - const chainMock = createQueryChain([]); - mockModel.find.mockReturnValue(chainMock); - - const countChainMock = createExecChain(150); - mockModel.countDocuments.mockReturnValue(countChainMock); - - await repository.query({ limit: 50, page: 2 }); - - expect(chainMock.skip).toHaveBeenCalledWith(50); - expect(chainMock.limit).toHaveBeenCalledWith(50); - }); - - it("should return pagination metadata", async () => { - const chainMock = createQueryChain([]); - mockModel.find.mockReturnValue(chainMock); - - const countChainMock = createExecChain(150); - mockModel.countDocuments.mockReturnValue(countChainMock); - - const result = await repository.query({ limit: 50, page: 1 }); - - expect(result.total).toBe(150); - expect(result.page).toBe(1); - expect(result.limit).toBe(50); - expect(result.pages).toBe(3); - }); - }); - - describe("count", () => { - it("should count all documents without filters", async () => { - const countChainMock = createExecChain(42); - mockModel.countDocuments.mockReturnValue(countChainMock); - - const count = await repository.count(); - - expect(mockModel.countDocuments).toHaveBeenCalledWith({}); - expect(count).toBe(42); - }); - - it("should count with filters", async () => { - const countChainMock = createExecChain(10); - mockModel.countDocuments.mockReturnValue(countChainMock); - - const count = await repository.count({ action: AuditActionType.CREATE }); - - expect(mockModel.countDocuments).toHaveBeenCalledWith({ action: AuditActionType.CREATE }); - expect(count).toBe(10); - }); - }); - - describe("exists", () => { - it("should return true when document exists", async () => { - const chainMock = createLeanExecChain({ id: "log-1" }); - mockModel.findOne.mockReturnValue(chainMock); - - const exists = await repository.exists({ action: AuditActionType.CREATE }); - - expect(exists).toBe(true); - }); - - it("should return false when document does not exist", async () => { - const chainMock = createLeanExecChain(null); - mockModel.findOne.mockReturnValue(chainMock); - - const exists = await repository.exists({ action: AuditActionType.DELETE }); - - expect(exists).toBe(false); - }); - }); - - describe("deleteOlderThan", () => { - it("should delete documents older than date", async () => { - const cutoffDate = new Date("2023-01-01"); - const deleteChainMock = createExecChain({ deletedCount: 5 }); - mockModel.deleteMany.mockReturnValue(deleteChainMock); - - const deleted = await repository.deleteOlderThan(cutoffDate); - - expect(mockModel.deleteMany).toHaveBeenCalledWith({ - timestamp: { $lt: cutoffDate }, - }); - expect(deleted).toBe(5); - }); - - it("should handle no deletions", async () => { - const deleteChainMock = createExecChain({ deletedCount: 0 }); - mockModel.deleteMany.mockReturnValue(deleteChainMock); - - const deleted = await repository.deleteOlderThan(new Date("2020-01-01")); - - expect(deleted).toBe(0); - }); - }); - - describe("document transformation", () => { - it("should transform _id to id in returned documents", async () => { - const log = createMockLog(); - const chainMock = createLeanExecChain({ - _id: log.id, - id: log.id, - timestamp: log.timestamp, - action: log.action, - actor: log.actor, - resource: log.resource, - }); - mockModel.findOne.mockReturnValue(chainMock); - - const found = await repository.findById(log.id); - - expect(found).toHaveProperty("id", log.id); - expect(found).not.toHaveProperty("_id"); - }); - - it("should handle null document", async () => { - const chainMock = createLeanExecChain(null); - mockModel.findOne.mockReturnValue(chainMock); - - const found = await repository.findById("non-existent"); - - expect(found).toBeNull(); - }); - - it("should transform array of documents", async () => { - const log1 = createMockLog({ id: "log-1" }); - const log2 = createMockLog({ id: "log-2" }); - const chainMock = createSortedLeanExecChain([ - { - _id: "mongodb-id-1", - id: log1.id, - timestamp: log1.timestamp, - action: log1.action, - actor: log1.actor, - resource: log1.resource, - }, - { - _id: "mongodb-id-2", - id: log2.id, - timestamp: log2.timestamp, - action: log2.action, - actor: log2.actor, - resource: log2.resource, - }, - ]); - mockModel.find.mockReturnValue(chainMock); - - const logs = await repository.findByActor("user-1"); - - expect(logs).toHaveLength(2); - expect(logs[0]).toHaveProperty("id"); - expect(logs[0]).not.toHaveProperty("_id"); - expect(logs[1]).toHaveProperty("id"); - expect(logs[1]).not.toHaveProperty("_id"); - }); - }); -}); diff --git a/src/infra/repositories/mongodb/mongo-audit.repository.ts b/src/infra/repositories/mongodb/mongo-audit.repository.ts deleted file mode 100644 index f662dcf..0000000 --- a/src/infra/repositories/mongodb/mongo-audit.repository.ts +++ /dev/null @@ -1,416 +0,0 @@ -/** - * ============================================================================ - * MONGODB AUDIT REPOSITORY ADAPTER - * ============================================================================ - * - * MongoDB implementation of the IAuditLogRepository port. - * - * Purpose: - * - Persist audit logs to MongoDB - * - Implement all query methods defined in the port - * - Leverage Mongoose for type safety and validation - * - Optimize queries with proper indexing - * - * Architecture: - * - Implements IAuditLogRepository (core port) - * - Uses Mongoose models and schemas - * - Can be swapped with other implementations (PostgreSQL, etc.) - * - * @packageDocumentation - */ - -import type { Model } from "mongoose"; - -import type { IAuditLogRepository } from "../../../core/ports/audit-repository.port"; -import type { - AuditLog, - AuditLogFilters, - CursorPageOptions, - CursorPageResult, - PageOptions, - PageResult, -} from "../../../core/types"; -import { decodeCursor, encodeCursor } from "../cursor.util"; - -import type { AuditLogDocument } from "./audit-log.schema"; - -// eslint-disable-next-line no-unused-vars -type ArchiveHandler = (logs: AuditLog[]) => Promise | void; - -/** - * MongoDB implementation of audit log repository. - * - * Uses Mongoose for: - * - Type-safe queries - * - Schema validation - * - Automatic connection management - * - Query optimization - * - * Key Features: - * - Immutable audit logs (no updates/deletes) - * - Optimized indexes for common query patterns - * - Supports complex filtering and pagination - * - Full-text search ready (if text index configured) - * - * @example - * ```typescript - * import mongoose from 'mongoose'; - * import { AuditLogSchema } from './audit-log.schema'; - * import { MongoAuditRepository } from './mongo-audit.repository'; - * - * const AuditLogModel = mongoose.model('AuditLog', AuditLogSchema); - * const repository = new MongoAuditRepository(AuditLogModel); - * ``` - */ -export class MongoAuditRepository implements IAuditLogRepository { - private readonly model: Model; - private readonly archiveHandler: ArchiveHandler | undefined; - - /** - * Creates a new MongoDB audit repository. - * - * @param model - Mongoose model for AuditLog - */ - constructor(model: Model, archiveHandler?: ArchiveHandler) { - this.model = model; - this.archiveHandler = archiveHandler; - } - - // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - // CREATE OPERATIONS - // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - /** - * Creates (persists) a new audit log entry. - * - * @param log - The audit log to persist - * @returns The persisted audit log - * @throws Error if persistence fails - */ - async create(log: AuditLog): Promise { - const document = new this.model(log); - const saved = await document.save(); - return this.toPlainObject(saved); - } - - // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - // READ OPERATIONS - Single Entity - // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - /** - * Finds a single audit log by ID. - * - * @param id - The audit log ID - * @returns The audit log if found, null otherwise - */ - async findById(id: string): Promise { - const document = await this.model.findOne({ id }).lean().exec(); - return document ? this.toPlainObject(document) : null; - } - - // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - // READ OPERATIONS - Collections - // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - /** - * Finds all audit logs for a specific actor. - * - * @param actorId - The actor's unique identifier - * @param filters - Optional additional filters - * @returns Array of audit logs - */ - async findByActor(actorId: string, filters?: Partial): Promise { - const query = this.buildQuery({ ...filters, actorId }); - const documents = await this.model.find(query).sort({ timestamp: -1 }).lean().exec(); - return documents.map((doc) => this.toPlainObject(doc)); - } - - /** - * Finds all audit logs for a specific resource. - * - * @param resourceType - The type of resource - * @param resourceId - The resource's unique identifier - * @param filters - Optional additional filters - * @returns Array of audit logs (chronological order) - */ - async findByResource( - resourceType: string, - resourceId: string, - filters?: Partial, - ): Promise { - const query = this.buildQuery({ ...filters, resourceType, resourceId }); - // Resource history should be chronological (oldest first) - const documents = await this.model.find(query).sort({ timestamp: 1 }).lean().exec(); - return documents.map((doc) => this.toPlainObject(doc)); - } - - /** - * Queries audit logs with complex filters and pagination. - * - * @param filters - Filter criteria and pagination options - * @returns Paginated result with data and metadata - */ - async query( - filters: Partial & Partial, - ): Promise> { - const { page = 1, limit = 20, sort = "-timestamp", ...queryFilters } = filters; - - // Build query - const query = this.buildQuery(queryFilters); - - // Parse sort (e.g., "-timestamp" or "+action") - const sortObject = this.parseSort(sort); - - // Execute query with pagination - const skip = (page - 1) * limit; - const [documents, total] = await Promise.all([ - this.model.find(query).sort(sortObject).skip(skip).limit(limit).lean().exec(), - this.model.countDocuments(query).exec(), - ]); - - const data = documents.map((doc) => this.toPlainObject(doc)); - const pages = Math.ceil(total / limit); - - return { - data, - page, - limit, - total, - pages, - }; - } - - // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - // READ OPERATIONS - Aggregation - // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - /** - * Counts audit logs matching the given filters. - * - * @param filters - Optional filter criteria - * @returns Number of matching audit logs - */ - async count(filters?: Partial): Promise { - const query = this.buildQuery(filters || {}); - return this.model.countDocuments(query).exec(); - } - - /** - * Checks if any audit log exists matching the filters. - * - * @param filters - Filter criteria - * @returns True if at least one audit log matches - */ - async exists(filters: Partial): Promise { - const query = this.buildQuery(filters); - const document = await this.model.findOne(query).lean().exec(); - return document !== null; - } - - // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - // OPTIONAL OPERATIONS - Advanced Features - // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - /** - * Deletes audit logs older than the specified date. - * - * ⚠️ CAUTION: This violates audit log immutability! - * Only use for compliance-mandated retention policies. - * - * @param beforeDate - Delete logs older than this date - * @returns Number of audit logs deleted - */ - async deleteOlderThan(beforeDate: Date): Promise { - const result = await this.model.deleteMany({ timestamp: { $lt: beforeDate } }).exec(); - return result.deletedCount || 0; - } - - /** - * Archives audit logs older than the specified date. - * - * If no archive handler is configured, this is a no-op. - * - * @param beforeDate - Archive logs older than this date - * @returns Number of archived logs - */ - async archiveOlderThan(beforeDate: Date): Promise { - if (!this.archiveHandler) { - return 0; - } - - const documents = await this.model - .find({ timestamp: { $lt: beforeDate } }) - .lean() - .exec(); - if (documents.length === 0) { - return 0; - } - - const logs = documents.map((doc) => this.toPlainObject(doc)); - await this.archiveHandler(logs); - return logs.length; - } - - /** - * Queries audit logs using cursor-based pagination. - * - * Results are sorted by `timestamp DESC, id ASC`. - * The cursor encodes the `{ timestamp, id }` of the last returned item. - * - * @param filters - Filter criteria - * @param options - Cursor and limit options - * @returns Cursor-paginated result - */ - async queryWithCursor( - filters: Partial, - options?: CursorPageOptions, - ): Promise> { - const limit = options?.limit ?? 20; - const query = this.buildQuery(filters); - - // Apply cursor constraint when provided - if (options?.cursor) { - const cursorData = decodeCursor(options.cursor); - const cursorDate = new Date(cursorData.t); - const cursorId = cursorData.id; - - // "After cursor" in descending-timestamp + ascending-id order: - // timestamp < cursorDate OR (timestamp == cursorDate AND id > cursorId) - query["$or"] = [ - { timestamp: { $lt: cursorDate } }, - { timestamp: cursorDate, id: { $gt: cursorId } }, - ]; - } - - // Fetch limit+1 to detect whether more pages exist - const documents = await this.model - .find(query) - .sort({ timestamp: -1, id: 1 }) - .limit(limit + 1) - .lean() - .exec(); - - const hasMore = documents.length > limit; - const pageDocuments = documents.slice(0, limit); - const data = pageDocuments.map((doc) => this.toPlainObject(doc)); - - const lastItem = data.at(-1); - const result: CursorPageResult = { - data, - hasMore, - limit, - }; - - if (hasMore && lastItem) { - result.nextCursor = encodeCursor({ t: lastItem.timestamp.getTime(), id: lastItem.id }); - } - - return result; - } - - // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - // PRIVATE HELPER METHODS - // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - - /** - * Builds MongoDB query from filters. - * - * Converts IAuditLogFilters to MongoDB query object. - * Handles nested fields (actor.id, resource.type, etc.). - * - * @param filters - Filter criteria - * @returns MongoDB query object - */ - private buildQuery(filters: Partial): Record { - const query: Record = {}; - this.applyActorFilters(query, filters); - this.applyResourceFilters(query, filters); - this.applyActionFilter(query, filters); - this.applyDateRangeFilter(query, filters); - this.applyMetadataFilters(query, filters); - if (filters.search) query.$text = { $search: filters.search }; - return query; - } - - private applyActorFilters(query: Record, filters: Partial): void { - if (filters.actorId) query["actor.id"] = filters.actorId; - if (filters.actorType) query["actor.type"] = filters.actorType; - } - - private applyResourceFilters( - query: Record, - filters: Partial, - ): void { - if (filters.resourceType) query["resource.type"] = filters.resourceType; - if (filters.resourceId) query["resource.id"] = filters.resourceId; - } - - private applyActionFilter(query: Record, filters: Partial): void { - if (filters.action) { - query.action = filters.action; - } else if (filters.actions && filters.actions.length > 0) { - query.action = { $in: filters.actions }; - } - } - - private applyDateRangeFilter( - query: Record, - filters: Partial, - ): void { - if (!filters.startDate && !filters.endDate) return; - query.timestamp = {}; - if (filters.startDate) query.timestamp.$gte = filters.startDate; - if (filters.endDate) query.timestamp.$lte = filters.endDate; - } - - private applyMetadataFilters( - query: Record, - filters: Partial, - ): void { - if (filters.ipAddress) query.ipAddress = filters.ipAddress; - if (filters.requestId) query.requestId = filters.requestId; - if (filters.sessionId) query.sessionId = filters.sessionId; - if (filters.idempotencyKey) query.idempotencyKey = filters.idempotencyKey; - } - - /** - * Parses sort string into MongoDB sort object. - * - * Supports: - * - "-timestamp" → { timestamp: -1 } (descending) - * - "+action" → { action: 1 } (ascending) - * - "timestamp" → { timestamp: 1 } (ascending, default) - * - * @param sort - Sort string - * @returns MongoDB sort object - */ - private parseSort(sort: string): Record { - if (sort.startsWith("-")) { - return { [sort.substring(1)]: -1 }; - } - if (sort.startsWith("+")) { - return { [sort.substring(1)]: 1 }; - } - return { [sort]: 1 }; - } - - /** - * Converts Mongoose document to plain AuditLog object. - * - * Removes Mongoose-specific properties (_id, __v, etc.). - * Ensures type safety and clean API responses. - * - * @param document - Mongoose document or lean object - * @returns Plain AuditLog object - */ - private toPlainObject(document: any): AuditLog { - // If it's a Mongoose document, convert to plain object - const plain = document.toObject ? document.toObject() : document; - - // Remove Mongoose-specific fields - // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars - const { _id, __v, ...rest } = plain; - - return rest as AuditLog; - } -} diff --git a/src/nest/interfaces.ts b/src/nest/interfaces.ts index 14330f6..54c5da5 100644 --- a/src/nest/interfaces.ts +++ b/src/nest/interfaces.ts @@ -13,12 +13,11 @@ */ import type { ModuleMetadata, Type } from "@nestjs/common"; -import type { Model } from "mongoose"; import type { IAuditEventPublisher } from "../core/ports/audit-event-publisher.port"; import type { IAuditObserver } from "../core/ports/audit-observer.port"; +import type { IAuditLogRepository } from "../core/ports/audit-repository.port"; import type { AuditLog } from "../core/types"; -import type { AuditLogDocument } from "../infra/repositories/mongodb/audit-log.schema"; // eslint-disable-next-line no-unused-vars export type ArchiveHandler = (logs: AuditLog[]) => Promise | void; @@ -28,54 +27,43 @@ export type ArchiveHandler = (logs: AuditLog[]) => Promise | void; // ============================================================================ /** - * MongoDB repository configuration. + * In-memory repository configuration. + * Useful for testing and simple deployments. */ -export interface MongoDbRepositoryConfig { +export interface InMemoryRepositoryConfig { /** * Repository type identifier. */ - type: "mongodb"; - - /** - * MongoDB connection URI. - * Required if not providing a model instance. - * - * @example 'mongodb://localhost:27017/auditdb' - */ - uri?: string; - - /** - * MongoDB database name. - */ - database?: string; + type: "in-memory"; /** - * Pre-configured Mongoose model for audit logs. - * If provided, uri and database are ignored. + * Optional initial data to seed the repository. */ - model?: Model; + initialData?: never; // Placeholder for future implementation } /** - * In-memory repository configuration. - * Useful for testing and simple deployments. + * Custom repository configuration. + * Use this to bring your own IAuditLogRepository implementation + * (e.g. from a shared database package). */ -export interface InMemoryRepositoryConfig { +export interface CustomRepositoryConfig { /** * Repository type identifier. */ - type: "in-memory"; + type: "custom"; /** - * Optional initial data to seed the repository. + * Pre-built IAuditLogRepository instance. + * This is the repository that will be used to persist audit logs. */ - initialData?: never; // Placeholder for future implementation + instance: IAuditLogRepository; } /** * Repository configuration union type. */ -export type RepositoryConfig = MongoDbRepositoryConfig | InMemoryRepositoryConfig; +export type RepositoryConfig = InMemoryRepositoryConfig | CustomRepositoryConfig; // ============================================================================ // UTILITY PROVIDER CONFIGURATION @@ -204,22 +192,21 @@ export interface EventStreamingConfig { /** * Configuration options for AuditKitModule. * - * @example Basic configuration with MongoDB + * @example With in-memory repository (testing) * ```typescript * AuditKitModule.register({ * repository: { - * type: 'mongodb', - * uri: 'mongodb://localhost:27017/auditdb', - * database: 'auditdb' + * type: 'in-memory' * } * }) * ``` * - * @example Configuration with in-memory repository + * @example With a custom repository (e.g. from a shared database package) * ```typescript * AuditKitModule.register({ * repository: { - * type: 'in-memory' + * type: 'custom', + * instance: new MyAuditRepository(dbConnection) * } * }) * ``` @@ -228,8 +215,8 @@ export interface EventStreamingConfig { * ```typescript * AuditKitModule.register({ * repository: { - * type: 'mongodb', - * uri: process.env.MONGO_URI + * type: 'custom', + * instance: myRepository * }, * idGenerator: { * type: 'nanoid', diff --git a/src/nest/module.spec.ts b/src/nest/module.spec.ts index 03e23e9..5f8e692 100644 --- a/src/nest/module.spec.ts +++ b/src/nest/module.spec.ts @@ -114,17 +114,23 @@ describe("AuditKitModule", () => { expect(service).toBeInstanceOf(AuditService); }); - it("should configure with MongoDB repository", async () => { - const mockModel = { - findOne: jest.fn(), + it("should configure with a custom repository", async () => { + const mockRepository = { + create: jest.fn(), + findById: jest.fn(), + findByActor: jest.fn(), + findByResource: jest.fn(), + query: jest.fn(), + count: jest.fn(), + exists: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ imports: [ AuditKitModule.register({ repository: { - type: "mongodb", - model: mockModel as any, + type: "custom", + instance: mockRepository as any, }, }), ], @@ -474,14 +480,14 @@ describe("AuditKitModule", () => { expect(service).toBeDefined(); }); - it("should throw for mongodb config without uri or model", async () => { + it("should throw for custom config without instance", async () => { expect(() => AuditKitModule.register({ repository: { - type: "mongodb", - }, + type: "custom", + } as any, }), - ).toThrow("MongoDB repository requires either 'uri' or 'model' to be configured"); + ).toThrow("Custom repository requires an 'instance' implementing IAuditLogRepository"); }); it("should throw for invalid retention days", async () => { diff --git a/src/nest/module.ts b/src/nest/module.ts index fbd1aef..1c238e1 100644 --- a/src/nest/module.ts +++ b/src/nest/module.ts @@ -12,15 +12,13 @@ * Module Exports: * - AuditService: Core service for creating and querying audit logs * - All utility providers (ID generator, timestamp, change detector) - * - Repository implementation (MongoDB or In-Memory) + * - Repository implementation (In-Memory or Custom) * * @packageDocumentation */ import { Module } from "@nestjs/common"; import type { DynamicModule } from "@nestjs/common"; -import type { ConnectOptions } from "mongoose"; -import { connect } from "mongoose"; import { AuditService } from "../core/audit.service"; import type { IAuditLogRepository } from "../core/ports/audit-repository.port"; @@ -32,8 +30,6 @@ import { EventEmitterAuditEventPublisher } from "../infra/providers/events/event import { NanoidIdGenerator } from "../infra/providers/id-generator/nanoid-id-generator"; import { SystemTimestampProvider } from "../infra/providers/timestamp/system-timestamp-provider"; import { InMemoryAuditRepository } from "../infra/repositories/in-memory/in-memory-audit.repository"; -import { AuditLogSchema } from "../infra/repositories/mongodb/audit-log.schema"; -import { MongoAuditRepository } from "../infra/repositories/mongodb/mongo-audit.repository"; import { AUDIT_KIT_OPTIONS, @@ -69,8 +65,7 @@ import { createAuditKitAsyncProviders, createAuditKitProviders } from "./provide * imports: [ * AuditKitModule.register({ * repository: { - * type: 'mongodb', - * uri: 'mongodb://localhost:27017/auditdb' + * type: 'in-memory' * } * }) * ] @@ -78,19 +73,15 @@ import { createAuditKitAsyncProviders, createAuditKitProviders } from "./provide * export class AppModule {} * ``` * - * @example Async registration with ConfigService + * @example With a custom repository from your database package * ```typescript * @Module({ * imports: [ - * AuditKitModule.registerAsync({ - * imports: [ConfigModule], - * inject: [ConfigService], - * useFactory: (config: ConfigService) => ({ - * repository: { - * type: 'mongodb', - * uri: config.get('MONGO_URI') - * } - * }) + * AuditKitModule.register({ + * repository: { + * type: 'custom', + * instance: new MyAuditRepository(dbConnection) + * } * }) * ] * }) @@ -204,9 +195,8 @@ export class AuditKitModule { * inject: [ConfigService], * useFactory: (config: ConfigService) => ({ * repository: { - * type: 'mongodb', - * uri: config.get('MONGO_URI'), - * database: config.get('MONGO_DB') + * type: 'custom', + * instance: new MyAuditRepository(config.get('DB_CONNECTION')) * }, * idGenerator: { * type: 'nanoid', @@ -225,8 +215,8 @@ export class AuditKitModule { * createAuditKitOptions(): AuditKitModuleOptions { * return { * repository: { - * type: 'mongodb', - * uri: this.config.get('MONGO_URI') + * type: 'custom', + * instance: new MyAuditRepository(this.config.get('DB_CONNECTION')) * } * }; * } @@ -331,28 +321,8 @@ export class AuditKitModule { const config = moduleOptions.repository; switch (config.type) { - case "mongodb": { - // If a model is provided, use it directly - if (config.model) { - return new MongoAuditRepository(config.model, getArchiveHandler(moduleOptions)); - } - - // Otherwise, create a connection and model - if (!config.uri) { - throw new Error( - "MongoDB repository requires either 'uri' or 'model' to be configured", - ); - } - - const connectionOptions: Partial = {}; - if (config.database !== undefined) { - connectionOptions.dbName = config.database; - } - - const connection = await connect(config.uri, connectionOptions as ConnectOptions); - const model = connection.model("AuditLog", AuditLogSchema); - return new MongoAuditRepository(model, getArchiveHandler(moduleOptions)); - } + case "custom": + return config.instance; case "in-memory": default: diff --git a/src/nest/options.validation.ts b/src/nest/options.validation.ts index f8f47c2..8376da0 100644 --- a/src/nest/options.validation.ts +++ b/src/nest/options.validation.ts @@ -55,12 +55,8 @@ function validateRepository(options: AuditKitModuleOptions): void { throw new Error("AuditKitModule options must include a repository configuration"); } - if ( - options.repository.type === "mongodb" && - !options.repository.uri && - !options.repository.model - ) { - throw new Error("MongoDB repository requires either 'uri' or 'model' to be configured"); + if (options.repository.type === "custom" && !options.repository.instance) { + throw new Error("Custom repository requires an 'instance' implementing IAuditLogRepository"); } } diff --git a/src/nest/providers.ts b/src/nest/providers.ts index 2bd59d1..5ddcc8f 100644 --- a/src/nest/providers.ts +++ b/src/nest/providers.ts @@ -14,8 +14,6 @@ */ import type { Provider } from "@nestjs/common"; -import type { ConnectOptions } from "mongoose"; -import { connect } from "mongoose"; import { AuditService } from "../core/audit.service"; import type { IAuditLogRepository } from "../core/ports/audit-repository.port"; @@ -27,8 +25,6 @@ import { EventEmitterAuditEventPublisher } from "../infra/providers/events/event import { NanoidIdGenerator } from "../infra/providers/id-generator/nanoid-id-generator"; import { SystemTimestampProvider } from "../infra/providers/timestamp/system-timestamp-provider"; import { InMemoryAuditRepository } from "../infra/repositories/in-memory/in-memory-audit.repository"; -import { AuditLogSchema } from "../infra/repositories/mongodb/audit-log.schema"; -import { MongoAuditRepository } from "../infra/repositories/mongodb/mongo-audit.repository"; import { AUDIT_KIT_OPTIONS, @@ -56,7 +52,7 @@ import { * 2. ID_GENERATOR - ID generation implementation * 3. TIMESTAMP_PROVIDER - Timestamp provider implementation * 4. CHANGE_DETECTOR - Change detection implementation - * 5. AUDIT_REPOSITORY - Repository implementation (MongoDB or In-Memory) + * 5. AUDIT_REPOSITORY - Repository implementation (In-Memory or Custom) * 6. AuditService - Core service (depends on all above) * * @param options - Module configuration options @@ -149,29 +145,8 @@ export function createAuditKitProviders(options: AuditKitModuleOptions): Provide const config = options.repository; switch (config.type) { - case "mongodb": { - // If a model is provided, use it directly - if (config.model) { - return new MongoAuditRepository(config.model, getArchiveHandler(options)); - } - - // Otherwise, create a connection and model - if (!config.uri) { - throw new Error( - "MongoDB repository requires either 'uri' or 'model' to be configured", - ); - } - - const connectionOptions: Partial = {}; - if (config.database !== undefined) { - connectionOptions.dbName = config.database; - } - - const connection = await connect(config.uri, connectionOptions as ConnectOptions); - - const model = connection.model("AuditLog", AuditLogSchema); - return new MongoAuditRepository(model, getArchiveHandler(options)); - } + case "custom": + return config.instance; case "in-memory": default: diff --git a/test/smoke.test.ts b/test/smoke.test.ts deleted file mode 100644 index 28325b1..0000000 --- a/test/smoke.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -test("smoke", () => { - expect(true).toBe(true); -});