diff --git a/backend/docs/automation-rules-audit.md b/backend/docs/automation-rules-audit.md new file mode 100644 index 00000000..f494c1ab --- /dev/null +++ b/backend/docs/automation-rules-audit.md @@ -0,0 +1,197 @@ +# Automation Rules Audit Trail + +This document describes the audit trail capabilities for automation rules, including rule change history, execution history, actor tracking, search, and export. + +## Overview + +The automation rules system keeps a complete, searchable, and exportable history of: + +1. **Rule changes** - every create, update, delete, activate, and deactivate event +2. **Rule executions** - every evaluation/run of an automation rule +3. **Actor tracking** - who or what initiated each change or execution +4. **Version snapshots** - full point-in-time snapshots of rule configuration + +This makes it possible to answer questions such as: + +- Who changed a rule and when? +- What did a rule look like at version N? +- How many times has a rule executed in the last 24 hours? +- Which user or system account triggered a specific execution? + +## Data Model + +### `automation_rules` + +Stores the current state of each automation rule. + +| Column | Type | Description | +| ----------------- | ------- | -------------------------------------------------------- | +| id | UUID | Primary key | +| name | text | Human-readable rule name | +| description | text | Optional description | +| asset_code | text | Asset the rule monitors | +| conditions | jsonb | Array of threshold conditions | +| logic_operator | text | `AND` or `OR` | +| actions | jsonb | Actions to execute when the rule triggers | +| status | text | `active`, `inactive`, or `draft` | +| owner_address | text | Rule owner | +| cooldown_seconds | integer | Minimum time between executions | +| last_executed_at | ts | Last execution timestamp | +| execution_count | integer | Total number of executions | +| version | integer | Monotonically increasing version number | +| created_at | ts | Creation timestamp | +| updated_at | ts | Last update timestamp | + +### `automation_rule_versions` + +Append-only audit log of rule changes. One row per change event. + +| Column | Type | Description | +| ------------- | ------- | -------------------------------------------------------- | +| id | UUID | Primary key | +| rule_id | UUID | Reference to `automation_rules` | +| version | integer | Version number at the time of the change | +| snapshot | jsonb | Full rule snapshot | +| changed_by | text | Actor that made the change | +| change_type | text | `create`, `update`, `delete`, `activate`, `deactivate` | +| change_reason | text | Optional human-readable reason | +| created_at | ts | Timestamp of the change | + +### `automation_rule_executions` + +Append-only history of rule executions. + +| Column | Type | Description | +| ----------------- | ------- | -------------------------------------------------------- | +| id | UUID | Primary key | +| rule_id | UUID | Reference to `automation_rules` | +| rule_version | integer | Rule version that was executed | +| input_metrics | jsonb | Metrics supplied to the rule | +| condition_results | jsonb | Result of each condition evaluation | +| triggered | bool | Whether the rule fired | +| actions_executed | jsonb | Actions that were run | +| action_results | jsonb | Per-action results | +| status | text | `completed`, `failed`, or `partial` | +| error_message | text | Error details when status is not `completed` | +| executed_by | text | Actor/system that triggered the execution | +| started_at | ts | Execution start time | +| completed_at | ts | Execution end time | +| duration_ms | int | Execution duration in milliseconds | +| created_at | ts | Row insertion time | + +### `rule_evaluator_logs` (enhanced) + +The existing rule evaluator log table has been extended with actor tracking columns: + +| Column | Type | Description | +| ----------------- | ------- | -------------------------------------------------------- | +| executed_by | text | Actor/system that triggered the evaluation | +| execution_context | text | Context such as `manual`, `scheduled`, `webhook`, `api` | +| metadata | jsonb | Additional execution metadata | + +## API Endpoints + +All endpoints are prefixed with `/api/v1/automation-rules` unless otherwise noted. + +### Rule Management + +| Method | Endpoint | Description | +| ------ | ---------------------- | -------------------------------------------------------- | +| GET | `/` | List automation rules | +| GET | `/:id` | Get a single rule | +| POST | `/` | Create a new rule | +| PATCH | `/:id` | Update a rule | +| DELETE | `/:id` | Delete a rule | +| POST | `/:id/activate` | Activate a rule | +| POST | `/:id/deactivate` | Deactivate a rule | + +### Audit Trail + +| Method | Endpoint | Description | +| ------ | ----------------------------------- | -------------------------------------------------------- | +| GET | `/history` | List rule change history | +| GET | `/history/search` | Search rule change history | +| GET | `/history/export` | Export rule change history as CSV | +| GET | `/:id/versions/:from/compare/:to` | Compare two rule versions | +| GET | `/executions` | List rule execution history | +| GET | `/executions/export` | Export execution history as CSV | + +### Rule Evaluator History + +The existing rule evaluator endpoints at `/api/v1/rule-evaluator` also support actor-aware queries: + +| Method | Endpoint | Description | +| ------ | -------------------- | -------------------------------------------------------- | +| GET | `/evaluate/history` | List evaluation history (supports `executedBy`, etc.) | + +## Query Parameters + +### Rule History + +- `ruleId` - filter by rule +- `changedBy` - filter by actor +- `changeType` - `create`, `update`, `delete`, `activate`, `deactivate` +- `from`, `to` - ISO 8601 date range +- `limit`, `offset` - pagination + +### Search Rule History + +Same filters as rule history plus: + +- `q` - free-text search across change reason and rule snapshot JSON + +### Execution History + +- `ruleId` - filter by rule +- `ruleVersion` - filter by rule version +- `triggered` - `true` or `false` +- `status` - `completed`, `failed`, `partial` +- `executedBy` - filter by actor +- `from`, `to` - ISO 8601 date range +- `limit`, `offset` - pagination + +## Actor Tracking + +Every rule change and execution records the actor that initiated it. + +- `user` - authenticated user address +- `api_key` - API key identifier +- `system` - internal scheduler or worker + +For rule evaluator calls, the request body may include: + +```json +{ + "executedBy": "user-123", + "executionContext": "manual" +} +``` + +If omitted, the evaluator defaults to `executedBy: "system"` and `executionContext: "api"`. + +## Export Format + +### Rule History CSV + +```csv +id,rule_id,version,change_type,changed_by,change_reason,created_at +"version-1","rule-1","1","create","user-1","Initial rule creation","2026-06-18T14:00:00.000Z" +``` + +### Execution History CSV + +```csv +id,rule_id,rule_version,triggered,status,executed_by,started_at,completed_at,duration_ms,created_at +"exec-1","rule-1","2","true","completed","system","2026-06-18T14:00:00.000Z","2026-06-18T14:00:01.000Z","100","2026-06-18T14:00:01.000Z" +``` + +## Integration with General Audit Log + +Rule creation, update, and deletion events are also written to the central `audit_logs` table via `auditService.log`. This enables cross-resource audit queries from `/api/v1/admin/audit` using: + +- `resourceType=automation_rule` +- `action=alert.rule_created`, `alert.rule_updated`, or `alert.rule_deleted` + +## Retention + +No automatic retention is applied by the new tables. Operators can add retention policies or periodic cleanup jobs based on `created_at` in `automation_rule_versions` and `automation_rule_executions`. diff --git a/backend/src/api/routes/automationRules.routes.ts b/backend/src/api/routes/automationRules.routes.ts new file mode 100644 index 00000000..3f496809 --- /dev/null +++ b/backend/src/api/routes/automationRules.routes.ts @@ -0,0 +1,444 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify"; +import "@fastify/rate-limit"; +import { + automationRulesService, + type AutomationRuleStatus, + type AutomationRuleChangeType, +} from "../../services/automationRules.service.js"; +import { authMiddleware } from "../middleware/auth.js"; +import type { RuleCondition, LogicOperator } from "../../services/ruleEvaluator.service.js"; + +// ============================================================================= +// TYPES +// ============================================================================= + +interface CreateRuleBody { + name: string; + description?: string; + assetCode: string; + conditions: RuleCondition[]; + logicOperator: LogicOperator; + actions: Array<{ + type: string; + config: Record; + }>; + status?: AutomationRuleStatus; + ownerAddress: string; + cooldownSeconds?: number; +} + +interface UpdateRuleBody { + name?: string; + description?: string; + assetCode?: string; + conditions?: RuleCondition[]; + logicOperator?: LogicOperator; + actions?: Array<{ + type: string; + config: Record; + }>; + status?: AutomationRuleStatus; + cooldownSeconds?: number; + changeReason?: string; +} + +interface RuleParams { + id: string; +} + +interface RuleQuerystring { + ownerAddress?: string; + assetCode?: string; + status?: AutomationRuleStatus; + limit?: string; + offset?: string; +} + +interface HistoryQuerystring { + ruleId?: string; + changedBy?: string; + changeType?: AutomationRuleChangeType; + from?: string; + to?: string; + limit?: string; + offset?: string; +} + +interface ExecutionHistoryQuerystring { + ruleId?: string; + ruleVersion?: string; + triggered?: string; + status?: string; + executedBy?: string; + from?: string; + to?: string; + limit?: string; + offset?: string; +} + +interface SearchQuerystring { + q?: string; + ruleId?: string; + changedBy?: string; + changeType?: AutomationRuleChangeType; + from?: string; + to?: string; + limit?: string; + offset?: string; +} + +interface VersionComparisonParams { + id: string; + from: string; + to: string; +} + +// ============================================================================= +// HELPERS +// ============================================================================= + +function getActor(request: FastifyRequest): { id: string; type: "user" | "api_key" | "system" } { + const apiKeyAuth = (request as any).apiKeyAuth; + if (apiKeyAuth?.keyId) { + return { id: apiKeyAuth.keyId, type: "api_key" }; + } + return { id: apiKeyAuth?.ownerAddress ?? "system", type: "user" }; +} + +function parseDate(value: string | undefined): Date | undefined { + return value ? new Date(value) : undefined; +} + +// ============================================================================= +// ROUTES +// ============================================================================= + +export async function automationRulesRoutes(server: FastifyInstance) { + const requireAuth = authMiddleware(); + const requireAuditRead = authMiddleware({ requiredScopes: ["admin:audit"] }); + + // --------------------------------------------------------------------------- + // LIST RULES + // --------------------------------------------------------------------------- + + server.get<{ Querystring: RuleQuerystring }>( + "/", + { preHandler: requireAuth, rateLimit: { max: 60, timeWindow: "1 minute" } } as any, + async (request: FastifyRequest<{ Querystring: RuleQuerystring }>, reply: FastifyReply) => { + try { + const result = await automationRulesService.listRules({ + ownerAddress: request.query.ownerAddress, + assetCode: request.query.assetCode, + status: request.query.status, + limit: request.query.limit ? parseInt(request.query.limit, 10) : undefined, + offset: request.query.offset ? parseInt(request.query.offset, 10) : undefined, + }); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to list automation rules"; + return reply.code(500).send({ error: message }); + } + } + ); + + // --------------------------------------------------------------------------- + // GET RULE + // --------------------------------------------------------------------------- + + server.get<{ Params: RuleParams }>( + "/:id", + { preHandler: requireAuth, rateLimit: { max: 60, timeWindow: "1 minute" } } as any, + async (request: FastifyRequest<{ Params: RuleParams }>, reply: FastifyReply) => { + try { + const rule = await automationRulesService.getRule(request.params.id); + if (!rule) { + return reply.code(404).send({ error: "Automation rule not found" }); + } + return rule; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to get automation rule"; + return reply.code(500).send({ error: message }); + } + } + ); + + // --------------------------------------------------------------------------- + // CREATE RULE + // --------------------------------------------------------------------------- + + server.post<{ Body: CreateRuleBody }>( + "/", + { preHandler: requireAuth, rateLimit: { max: 30, timeWindow: "1 minute" } } as any, + async (request: FastifyRequest<{ Body: CreateRuleBody }>, reply: FastifyReply) => { + try { + const rule = await automationRulesService.createRule(request.body, getActor(request)); + return reply.code(201).send(rule); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to create automation rule"; + return reply.code(400).send({ error: message }); + } + } + ); + + // --------------------------------------------------------------------------- + // UPDATE RULE + // --------------------------------------------------------------------------- + + server.patch<{ Params: RuleParams; Body: UpdateRuleBody }>( + "/:id", + { preHandler: requireAuth, rateLimit: { max: 30, timeWindow: "1 minute" } } as any, + async (request: FastifyRequest<{ Params: RuleParams; Body: UpdateRuleBody }>, reply: FastifyReply) => { + try { + const { changeReason, ...updates } = request.body; + const rule = await automationRulesService.updateRule( + request.params.id, + updates, + getActor(request), + changeReason + ); + if (!rule) { + return reply.code(404).send({ error: "Automation rule not found" }); + } + return rule; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to update automation rule"; + return reply.code(400).send({ error: message }); + } + } + ); + + // --------------------------------------------------------------------------- + // DELETE RULE + // --------------------------------------------------------------------------- + + server.delete<{ Params: RuleParams; Body?: { changeReason?: string } }>( + "/:id", + { preHandler: requireAuth, rateLimit: { max: 30, timeWindow: "1 minute" } } as any, + async (request: FastifyRequest<{ Params: RuleParams; Body?: { changeReason?: string } }>, reply: FastifyReply) => { + try { + const deleted = await automationRulesService.deleteRule( + request.params.id, + getActor(request), + request.body?.changeReason + ); + if (!deleted) { + return reply.code(404).send({ error: "Automation rule not found" }); + } + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to delete automation rule"; + return reply.code(500).send({ error: message }); + } + } + ); + + // --------------------------------------------------------------------------- + // ACTIVATE / DEACTIVATE + // --------------------------------------------------------------------------- + + server.post<{ Params: RuleParams; Body?: { changeReason?: string } }>( + "/:id/activate", + { preHandler: requireAuth, rateLimit: { max: 30, timeWindow: "1 minute" } } as any, + async (request: FastifyRequest<{ Params: RuleParams; Body?: { changeReason?: string } }>, reply: FastifyReply) => { + try { + const rule = await automationRulesService.activateRule( + request.params.id, + getActor(request), + request.body?.changeReason + ); + if (!rule) { + return reply.code(404).send({ error: "Automation rule not found" }); + } + return rule; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to activate automation rule"; + return reply.code(500).send({ error: message }); + } + } + ); + + server.post<{ Params: RuleParams; Body?: { changeReason?: string } }>( + "/:id/deactivate", + { preHandler: requireAuth, rateLimit: { max: 30, timeWindow: "1 minute" } } as any, + async (request: FastifyRequest<{ Params: RuleParams; Body?: { changeReason?: string } }>, reply: FastifyReply) => { + try { + const rule = await automationRulesService.deactivateRule( + request.params.id, + getActor(request), + request.body?.changeReason + ); + if (!rule) { + return reply.code(404).send({ error: "Automation rule not found" }); + } + return rule; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to deactivate automation rule"; + return reply.code(500).send({ error: message }); + } + } + ); + + // --------------------------------------------------------------------------- + // RULE HISTORY (AUDIT TRAIL) + // --------------------------------------------------------------------------- + + server.get<{ Querystring: HistoryQuerystring }>( + "/history", + { preHandler: requireAuditRead, rateLimit: { max: 60, timeWindow: "1 minute" } } as any, + async (request: FastifyRequest<{ Querystring: HistoryQuerystring }>, reply: FastifyReply) => { + try { + const result = await automationRulesService.getRuleHistory({ + ruleId: request.query.ruleId, + changedBy: request.query.changedBy, + changeType: request.query.changeType, + from: parseDate(request.query.from), + to: parseDate(request.query.to), + limit: request.query.limit ? parseInt(request.query.limit, 10) : undefined, + offset: request.query.offset ? parseInt(request.query.offset, 10) : undefined, + }); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to get rule history"; + return reply.code(500).send({ error: message }); + } + } + ); + + // --------------------------------------------------------------------------- + // RULE HISTORY SEARCH + // --------------------------------------------------------------------------- + + server.get<{ Querystring: SearchQuerystring }>( + "/history/search", + { preHandler: requireAuditRead, rateLimit: { max: 60, timeWindow: "1 minute" } } as any, + async (request: FastifyRequest<{ Querystring: SearchQuerystring }>, reply: FastifyReply) => { + try { + const result = await automationRulesService.searchRuleHistory({ + q: request.query.q, + ruleId: request.query.ruleId, + changedBy: request.query.changedBy, + changeType: request.query.changeType, + from: parseDate(request.query.from), + to: parseDate(request.query.to), + limit: request.query.limit ? parseInt(request.query.limit, 10) : undefined, + offset: request.query.offset ? parseInt(request.query.offset, 10) : undefined, + }); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to search rule history"; + return reply.code(500).send({ error: message }); + } + } + ); + + // --------------------------------------------------------------------------- + // RULE VERSION COMPARISON + // --------------------------------------------------------------------------- + + server.get<{ Params: VersionComparisonParams }>( + "/:id/versions/:from/compare/:to", + { preHandler: requireAuditRead, rateLimit: { max: 60, timeWindow: "1 minute" } } as any, + async (request: FastifyRequest<{ Params: VersionComparisonParams }>, reply: FastifyReply) => { + try { + const comparison = await automationRulesService.compareVersions( + request.params.id, + parseInt(request.params.from, 10), + parseInt(request.params.to, 10) + ); + if (!comparison) { + return reply.code(404).send({ error: "Rule versions not found" }); + } + return comparison; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to compare versions"; + return reply.code(500).send({ error: message }); + } + } + ); + + // --------------------------------------------------------------------------- + // EXECUTION HISTORY + // --------------------------------------------------------------------------- + + server.get<{ Querystring: ExecutionHistoryQuerystring }>( + "/executions", + { preHandler: requireAuditRead, rateLimit: { max: 60, timeWindow: "1 minute" } } as any, + async (request: FastifyRequest<{ Querystring: ExecutionHistoryQuerystring }>, reply: FastifyReply) => { + try { + const result = await automationRulesService.getExecutionHistory({ + ruleId: request.query.ruleId, + ruleVersion: request.query.ruleVersion ? parseInt(request.query.ruleVersion, 10) : undefined, + triggered: request.query.triggered !== undefined ? request.query.triggered === "true" : undefined, + status: request.query.status, + executedBy: request.query.executedBy, + from: parseDate(request.query.from), + to: parseDate(request.query.to), + limit: request.query.limit ? parseInt(request.query.limit, 10) : undefined, + offset: request.query.offset ? parseInt(request.query.offset, 10) : undefined, + }); + return result; + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to get execution history"; + return reply.code(500).send({ error: message }); + } + } + ); + + // --------------------------------------------------------------------------- + // EXPORT RULE HISTORY CSV + // --------------------------------------------------------------------------- + + server.get<{ Querystring: HistoryQuerystring }>( + "/history/export", + { preHandler: requireAuditRead, rateLimit: { max: 10, timeWindow: "1 minute" } } as any, + async (request: FastifyRequest<{ Querystring: HistoryQuerystring }>, reply: FastifyReply) => { + try { + const csv = await automationRulesService.exportRuleHistoryCsv({ + ruleId: request.query.ruleId, + changedBy: request.query.changedBy, + changeType: request.query.changeType, + from: parseDate(request.query.from), + to: parseDate(request.query.to), + }); + return reply + .code(200) + .header("Content-Type", "text/csv") + .header("Content-Disposition", `attachment; filename="automation-rule-history-${Date.now()}.csv"`) + .send(csv); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to export rule history"; + return reply.code(500).send({ error: message }); + } + } + ); + + // --------------------------------------------------------------------------- + // EXPORT EXECUTION HISTORY CSV + // --------------------------------------------------------------------------- + + server.get<{ Querystring: ExecutionHistoryQuerystring }>( + "/executions/export", + { preHandler: requireAuditRead, rateLimit: { max: 10, timeWindow: "1 minute" } } as any, + async (request: FastifyRequest<{ Querystring: ExecutionHistoryQuerystring }>, reply: FastifyReply) => { + try { + const csv = await automationRulesService.exportExecutionHistoryCsv({ + ruleId: request.query.ruleId, + ruleVersion: request.query.ruleVersion ? parseInt(request.query.ruleVersion, 10) : undefined, + triggered: request.query.triggered !== undefined ? request.query.triggered === "true" : undefined, + status: request.query.status, + executedBy: request.query.executedBy, + from: parseDate(request.query.from), + to: parseDate(request.query.to), + }); + return reply + .code(200) + .header("Content-Type", "text/csv") + .header("Content-Disposition", `attachment; filename="automation-rule-executions-${Date.now()}.csv"`) + .send(csv); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to export execution history"; + return reply.code(500).send({ error: message }); + } + } + ); +} diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index e12c84e0..745a2b2a 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -55,6 +55,7 @@ import { notificationTemplatesRoutes } from "./notificationTemplates.js"; import { archivedDataBrowserRoutes } from "./archivedDataBrowser.routes.js"; import { circuitHealthRoutes } from "./circuitHealth.js"; import { ruleEvaluatorRoutes } from "./ruleEvaluator.routes.js"; +import { automationRulesRoutes } from "./automationRules.routes.js"; import { serviceAnnotationRoutes } from "./serviceAnnotation.routes.js"; import { assetMergeRoutes } from "./assetMerge.routes.js"; import { alertWindowingRoutes } from "./alertWindowing.routes.js"; @@ -148,6 +149,7 @@ export async function registerRoutes(server: FastifyInstance) { }); server.register(archivedDataBrowserRoutes, { prefix: "/api/v1/archive" }); server.register(ruleEvaluatorRoutes, { prefix: "/api/v1/rule-evaluator" }); + server.register(automationRulesRoutes, { prefix: "/api/v1/automation-rules" }); server.register(serviceAnnotationRoutes, { prefix: "/api/v1/service-annotations", }); diff --git a/backend/src/api/routes/ruleEvaluator.routes.ts b/backend/src/api/routes/ruleEvaluator.routes.ts index 650fba5d..cdc6038b 100644 --- a/backend/src/api/routes/ruleEvaluator.routes.ts +++ b/backend/src/api/routes/ruleEvaluator.routes.ts @@ -14,6 +14,8 @@ interface EvaluateBody { metrics: Record; previousMetrics?: Record; previewMode?: boolean; + executedBy?: string; + executionContext?: string; } interface EvaluateBatchBody { @@ -27,12 +29,18 @@ interface EvaluateBatchBody { metrics: Record; previousMetrics?: Record; previewMode?: boolean; + executedBy?: string; + executionContext?: string; } interface HistoryQuery { ruleId?: string; assetCode?: string; triggered?: string; + executedBy?: string; + executionContext?: string; + from?: string; + to?: string; limit?: string; offset?: string; } @@ -42,12 +50,23 @@ export async function ruleEvaluatorRoutes(server: FastifyInstance) { "/evaluate", async (request: FastifyRequest<{ Body: EvaluateBody }>, reply: FastifyReply) => { try { - const { ruleName, ruleId, assetCode, conditions, logicOperator, metrics, previousMetrics, previewMode } = request.body; + const { + ruleName, + ruleId, + assetCode, + conditions, + logicOperator, + metrics, + previousMetrics, + previewMode, + executedBy, + executionContext, + } = request.body; const result = ruleEvaluatorService.evaluate( { ruleName, ruleId, assetCode, conditions, logicOperator }, metrics, previousMetrics, - previewMode + { previewMode, executedBy, executionContext } ); return result; } catch (error) { @@ -61,12 +80,19 @@ export async function ruleEvaluatorRoutes(server: FastifyInstance) { "/evaluate/batch", async (request: FastifyRequest<{ Body: EvaluateBatchBody }>, reply: FastifyReply) => { try { - const { rules, metrics, previousMetrics, previewMode } = request.body; + const { + rules, + metrics, + previousMetrics, + previewMode, + executedBy, + executionContext, + } = request.body; const results = ruleEvaluatorService.evaluateBatch( rules, metrics, previousMetrics, - previewMode + { previewMode, executedBy, executionContext } ); return { evaluatedAt: new Date().toISOString(), @@ -83,11 +109,25 @@ export async function ruleEvaluatorRoutes(server: FastifyInstance) { server.get<{ Querystring: HistoryQuery }>( "/evaluate/history", async (request: FastifyRequest<{ Querystring: HistoryQuery }>) => { - const { ruleId, assetCode, triggered, limit, offset } = request.query; + const { + ruleId, + assetCode, + triggered, + executedBy, + executionContext, + from, + to, + limit, + offset, + } = request.query; return ruleEvaluatorService.getEvaluationHistory({ ruleId, assetCode, triggered: triggered !== undefined ? triggered === "true" : undefined, + executedBy, + executionContext, + from: from ? new Date(from) : undefined, + to: to ? new Date(to) : undefined, limit: limit ? parseInt(limit, 10) : undefined, offset: offset ? parseInt(offset, 10) : undefined, }); diff --git a/backend/src/database/migrations/033_automation_rules_audit.ts b/backend/src/database/migrations/033_automation_rules_audit.ts new file mode 100644 index 00000000..77b8e7d3 --- /dev/null +++ b/backend/src/database/migrations/033_automation_rules_audit.ts @@ -0,0 +1,98 @@ +import type { Knex } from "knex"; + +export async function up(knex: Knex): Promise { + // Automation rules table + await knex.schema.createTable("automation_rules", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table.string("name").notNullable(); + table.text("description").nullable(); + table.string("asset_code").notNullable(); + table.jsonb("conditions").notNullable(); + table.string("logic_operator").notNullable().defaultTo("AND"); + table.jsonb("actions").notNullable(); // Array of actions to execute when triggered + table.string("status").notNullable().defaultTo("active"); // active, inactive, draft + table.string("owner_address").notNullable(); + table.integer("cooldown_seconds").notNullable().defaultTo(3600); + table.timestamp("last_executed_at").nullable(); + table.integer("execution_count").notNullable().defaultTo(0); + table.integer("version").notNullable().defaultTo(1); + table.timestamp("created_at").notNullable().defaultTo(knex.fn.now()); + table.timestamp("updated_at").notNullable().defaultTo(knex.fn.now()); + + table.index(["owner_address"]); + table.index(["asset_code", "status"]); + table.index(["status"]); + table.index(["updated_at"]); + }); + + // Automation rule versions - audit trail for rule changes + await knex.schema.createTable("automation_rule_versions", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table + .uuid("rule_id") + .notNullable() + .references("id") + .inTable("automation_rules") + .onDelete("CASCADE"); + table.integer("version").notNullable(); + table.jsonb("snapshot").notNullable(); // Full rule snapshot + table.string("changed_by").notNullable(); // Actor who made the change + table.string("change_type").notNullable().defaultTo("update"); // create, update, delete, activate, deactivate + table.text("change_reason").nullable(); + table.timestamp("created_at").notNullable().defaultTo(knex.fn.now()); + + table.index(["rule_id", "version"]); + table.index(["changed_by"]); + table.index(["created_at"]); + }); + + // Automation rule executions - history of rule runs + await knex.schema.createTable("automation_rule_executions", (table) => { + table.uuid("id").primary().defaultTo(knex.raw("gen_random_uuid()")); + table + .uuid("rule_id") + .notNullable() + .references("id") + .inTable("automation_rules") + .onDelete("CASCADE"); + table.integer("rule_version").notNullable(); + table.jsonb("input_metrics").notNullable(); + table.jsonb("condition_results").notNullable(); + table.boolean("triggered").notNullable(); + table.jsonb("actions_executed").nullable(); // Results of action execution + table.jsonb("action_results").nullable(); // Detailed results per action + table.string("status").notNullable().defaultTo("completed"); // completed, failed, partial + table.text("error_message").nullable(); + table.string("executed_by").notNullable(); // Actor who triggered execution (system, user, schedule) + table.timestamp("started_at").notNullable(); + table.timestamp("completed_at").nullable(); + table.integer("duration_ms").nullable(); + table.timestamp("created_at").notNullable().defaultTo(knex.fn.now()); + + table.index(["rule_id", "created_at"]); + table.index(["rule_version"]); + table.index(["executed_by"]); + table.index(["status"]); + table.index(["triggered"]); + table.index(["created_at"]); + }); + + // Add actor tracking columns to existing rule_evaluator_logs table + await knex.schema.alterTable("rule_evaluator_logs", (table) => { + table.string("executed_by").nullable(); // Actor who triggered evaluation (system, user, schedule) + table.string("execution_context").nullable(); // Context: manual, scheduled, webhook, api + table.jsonb("metadata").nullable().defaultTo("{}"); // Additional metadata + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists("automation_rule_executions"); + await knex.schema.dropTableIfExists("automation_rule_versions"); + await knex.schema.dropTableIfExists("automation_rules"); + + await knex.schema.alterTable("rule_evaluator_logs", (table) => { + table.dropColumn("executed_by"); + table.dropColumn("execution_context"); + table.dropColumn("metadata"); + }); +} \ No newline at end of file diff --git a/backend/src/services/automationRules.service.ts b/backend/src/services/automationRules.service.ts new file mode 100644 index 00000000..dd8bc197 --- /dev/null +++ b/backend/src/services/automationRules.service.ts @@ -0,0 +1,734 @@ +import crypto from "crypto"; +import { getDatabase } from "../database/connection.js"; +import { logger } from "../utils/logger.js"; +import { auditService } from "./audit.service.js"; +import type { LogicOperator, RuleCondition } from "./ruleEvaluator.service.js"; + +// ============================================================================= +// TYPES +// ============================================================================= + +export type AutomationRuleStatus = "active" | "inactive" | "draft"; +export type AutomationRuleChangeType = "create" | "update" | "delete" | "activate" | "deactivate"; + +export interface AutomationRuleAction { + type: string; + config: Record; +} + +export interface AutomationRule { + id: string; + name: string; + description: string | null; + assetCode: string; + conditions: RuleCondition[]; + logicOperator: LogicOperator; + actions: AutomationRuleAction[]; + status: AutomationRuleStatus; + ownerAddress: string; + cooldownSeconds: number; + lastExecutedAt: Date | null; + executionCount: number; + version: number; + createdAt: Date; + updatedAt: Date; +} + +export interface CreateAutomationRuleInput { + name: string; + description?: string; + assetCode: string; + conditions: RuleCondition[]; + logicOperator: LogicOperator; + actions: AutomationRuleAction[]; + status?: AutomationRuleStatus; + ownerAddress: string; + cooldownSeconds?: number; +} + +export interface UpdateAutomationRuleInput { + name?: string; + description?: string; + assetCode?: string; + conditions?: RuleCondition[]; + logicOperator?: LogicOperator; + actions?: AutomationRuleAction[]; + status?: AutomationRuleStatus; + cooldownSeconds?: number; +} + +export interface AutomationRuleVersion { + id: string; + ruleId: string; + version: number; + snapshot: Record; + changedBy: string; + changeType: AutomationRuleChangeType; + changeReason: string | null; + createdAt: Date; +} + +export interface AutomationRuleExecution { + id: string; + ruleId: string; + ruleVersion: number; + inputMetrics: Record; + conditionResults: Record[]; + triggered: boolean; + actionsExecuted: AutomationRuleAction[] | null; + actionResults: Record[] | null; + status: "completed" | "failed" | "partial"; + errorMessage: string | null; + executedBy: string; + startedAt: Date; + completedAt: Date | null; + durationMs: number | null; + createdAt: Date; +} + +export interface RuleHistoryQuery { + ruleId?: string; + changedBy?: string; + changeType?: AutomationRuleChangeType; + from?: Date; + to?: Date; + limit?: number; + offset?: number; +} + +export interface ExecutionHistoryQuery { + ruleId?: string; + ruleVersion?: number; + triggered?: boolean; + status?: string; + executedBy?: string; + from?: Date; + to?: Date; + limit?: number; + offset?: number; +} + +// ============================================================================= +// AUTOMATION RULES SERVICE +// ============================================================================= + +export class AutomationRulesService { + private static instance: AutomationRulesService; + + private constructor() {} + + public static getInstance(): AutomationRulesService { + if (!AutomationRulesService.instance) { + AutomationRulesService.instance = new AutomationRulesService(); + } + return AutomationRulesService.instance; + } + + // --------------------------------------------------------------------------- + // RULE CRUD + // --------------------------------------------------------------------------- + + public async createRule( + input: CreateAutomationRuleInput, + actor: { id: string; type?: "user" | "api_key" | "system" } + ): Promise { + const db = getDatabase(); + const now = new Date(); + + const [row] = await db("automation_rules") + .insert({ + id: crypto.randomUUID(), + name: input.name, + description: input.description ?? null, + asset_code: input.assetCode, + conditions: JSON.stringify(input.conditions), + logic_operator: input.logicOperator, + actions: JSON.stringify(input.actions), + status: input.status ?? "draft", + owner_address: input.ownerAddress, + cooldown_seconds: input.cooldownSeconds ?? 3600, + last_executed_at: null, + execution_count: 0, + version: 1, + created_at: now, + updated_at: now, + }) + .returning("*"); + + const rule = this.mapRule(row); + + await this.createVersionSnapshot(rule, actor.id, "create", "Initial rule creation"); + await this.logRuleAudit("alert.rule_created", rule, actor, null, this.ruleToSnapshot(rule)); + + logger.info({ ruleId: rule.id, name: rule.name, actorId: actor.id }, "Automation rule created"); + return rule; + } + + public async getRule(id: string): Promise { + const db = getDatabase(); + const row = await db("automation_rules").where({ id }).first(); + return row ? this.mapRule(row) : null; + } + + public async listRules(params: { + ownerAddress?: string; + assetCode?: string; + status?: AutomationRuleStatus; + limit?: number; + offset?: number; + } = {}): Promise<{ rules: AutomationRule[]; total: number }> { + const db = getDatabase(); + const limit = Math.min(params.limit ?? 100, 1000); + const offset = params.offset ?? 0; + + let query = db("automation_rules"); + let countQuery = db("automation_rules"); + + if (params.ownerAddress) { + query = query.where("owner_address", params.ownerAddress); + countQuery = countQuery.where("owner_address", params.ownerAddress); + } + if (params.assetCode) { + query = query.where("asset_code", params.assetCode); + countQuery = countQuery.where("asset_code", params.assetCode); + } + if (params.status) { + query = query.where("status", params.status); + countQuery = countQuery.where("status", params.status); + } + + const [rows, countResult] = await Promise.all([ + query.orderBy("updated_at", "desc").limit(limit).offset(offset), + countQuery.count("id as count").first(), + ]); + + return { + rules: rows.map((r: Record) => this.mapRule(r)), + total: Number(countResult?.count ?? 0), + }; + } + + public async updateRule( + id: string, + input: UpdateAutomationRuleInput, + actor: { id: string; type?: "user" | "api_key" | "system" }, + changeReason?: string + ): Promise { + const db = getDatabase(); + const existing = await this.getRule(id); + if (!existing) return null; + + const before = this.ruleToSnapshot(existing); + const updates: Record = { + updated_at: new Date(), + }; + + if (input.name !== undefined) updates.name = input.name; + if (input.description !== undefined) updates.description = input.description; + if (input.assetCode !== undefined) updates.asset_code = input.assetCode; + if (input.conditions !== undefined) updates.conditions = JSON.stringify(input.conditions); + if (input.logicOperator !== undefined) updates.logic_operator = input.logicOperator; + if (input.actions !== undefined) updates.actions = JSON.stringify(input.actions); + if (input.status !== undefined) updates.status = input.status; + if (input.cooldownSeconds !== undefined) updates.cooldown_seconds = input.cooldownSeconds; + + updates.version = existing.version + 1; + + const [row] = await db("automation_rules").where({ id }).update(updates).returning("*"); + const updated = this.mapRule(row); + const after = this.ruleToSnapshot(updated); + + await this.createVersionSnapshot( + updated, + actor.id, + input.status ? this.statusToChangeType(input.status) : "update", + changeReason ?? null + ); + await this.logRuleAudit("alert.rule_updated", updated, actor, before, after); + + logger.info({ ruleId: updated.id, version: updated.version, actorId: actor.id }, "Automation rule updated"); + return updated; + } + + public async deleteRule( + id: string, + actor: { id: string; type?: "user" | "api_key" | "system" }, + changeReason?: string + ): Promise { + const db = getDatabase(); + const existing = await this.getRule(id); + if (!existing) return false; + + const before = this.ruleToSnapshot(existing); + + await this.createVersionSnapshot(existing, actor.id, "delete", changeReason ?? null); + await db("automation_rules").where({ id }).delete(); + await this.logRuleAudit("alert.rule_deleted", existing, actor, before, null); + + logger.info({ ruleId: id, actorId: actor.id }, "Automation rule deleted"); + return true; + } + + // --------------------------------------------------------------------------- + // ACTIVATION / DEACTIVATION + // --------------------------------------------------------------------------- + + public async activateRule( + id: string, + actor: { id: string; type?: "user" | "api_key" | "system" }, + changeReason?: string + ): Promise { + return this.updateRule(id, { status: "active" }, actor, changeReason ?? "Rule activated"); + } + + public async deactivateRule( + id: string, + actor: { id: string; type?: "user" | "api_key" | "system" }, + changeReason?: string + ): Promise { + return this.updateRule(id, { status: "inactive" }, actor, changeReason ?? "Rule deactivated"); + } + + // --------------------------------------------------------------------------- + // VERSION HISTORY + // --------------------------------------------------------------------------- + + public async getRuleHistory(params: RuleHistoryQuery = {}): Promise<{ versions: AutomationRuleVersion[]; total: number }> { + const db = getDatabase(); + const limit = Math.min(params.limit ?? 100, 1000); + const offset = params.offset ?? 0; + + let query = db("automation_rule_versions"); + let countQuery = db("automation_rule_versions"); + + if (params.ruleId) { + query = query.where("rule_id", params.ruleId); + countQuery = countQuery.where("rule_id", params.ruleId); + } + if (params.changedBy) { + query = query.where("changed_by", params.changedBy); + countQuery = countQuery.where("changed_by", params.changedBy); + } + if (params.changeType) { + query = query.where("change_type", params.changeType); + countQuery = countQuery.where("change_type", params.changeType); + } + if (params.from) { + query = query.where("created_at", ">=", params.from); + countQuery = countQuery.where("created_at", ">=", params.from); + } + if (params.to) { + query = query.where("created_at", "<=", params.to); + countQuery = countQuery.where("created_at", "<=", params.to); + } + + const [rows, countResult] = await Promise.all([ + query.orderBy("created_at", "desc").limit(limit).offset(offset), + countQuery.count("id as count").first(), + ]); + + return { + versions: rows.map((r: Record) => this.mapVersion(r)), + total: Number(countResult?.count ?? 0), + }; + } + + public async getRuleVersion(ruleId: string, version: number): Promise { + const db = getDatabase(); + const row = await db("automation_rule_versions") + .where({ rule_id: ruleId, version }) + .first(); + return row ? this.mapVersion(row) : null; + } + + public async compareVersions( + ruleId: string, + fromVersion: number, + toVersion: number + ): Promise<{ from: Record; to: Record; diff: Record } | null> { + const [from, to] = await Promise.all([ + this.getRuleVersion(ruleId, fromVersion), + this.getRuleVersion(ruleId, toVersion), + ]); + + if (!from || !to) return null; + + return { + from: from.snapshot, + to: to.snapshot, + diff: this.computeDiff(from.snapshot, to.snapshot), + }; + } + + // --------------------------------------------------------------------------- + // EXECUTION HISTORY + // --------------------------------------------------------------------------- + + public async recordExecution( + params: Omit + ): Promise { + const db = getDatabase(); + const [row] = await db("automation_rule_executions") + .insert({ + id: crypto.randomUUID(), + rule_id: params.ruleId, + rule_version: params.ruleVersion, + input_metrics: JSON.stringify(params.inputMetrics), + condition_results: JSON.stringify(params.conditionResults), + triggered: params.triggered, + actions_executed: params.actionsExecuted ? JSON.stringify(params.actionsExecuted) : null, + action_results: params.actionResults ? JSON.stringify(params.actionResults) : null, + status: params.status, + error_message: params.errorMessage, + executed_by: params.executedBy, + started_at: params.startedAt, + completed_at: params.completedAt, + duration_ms: params.durationMs, + created_at: new Date(), + }) + .returning("*"); + + // Update rule execution metadata + await db("automation_rules") + .where({ id: params.ruleId }) + .update({ + last_executed_at: params.completedAt ?? new Date(), + execution_count: db.raw("execution_count + 1"), + }); + + return this.mapExecution(row); + } + + public async getExecutionHistory( + params: ExecutionHistoryQuery = {} + ): Promise<{ executions: AutomationRuleExecution[]; total: number }> { + const db = getDatabase(); + const limit = Math.min(params.limit ?? 100, 1000); + const offset = params.offset ?? 0; + + let query = db("automation_rule_executions"); + let countQuery = db("automation_rule_executions"); + + if (params.ruleId) { + query = query.where("rule_id", params.ruleId); + countQuery = countQuery.where("rule_id", params.ruleId); + } + if (params.ruleVersion !== undefined) { + query = query.where("rule_version", params.ruleVersion); + countQuery = countQuery.where("rule_version", params.ruleVersion); + } + if (params.triggered !== undefined) { + query = query.where("triggered", params.triggered); + countQuery = countQuery.where("triggered", params.triggered); + } + if (params.status) { + query = query.where("status", params.status); + countQuery = countQuery.where("status", params.status); + } + if (params.executedBy) { + query = query.where("executed_by", params.executedBy); + countQuery = countQuery.where("executed_by", params.executedBy); + } + if (params.from) { + query = query.where("created_at", ">=", params.from); + countQuery = countQuery.where("created_at", ">=", params.from); + } + if (params.to) { + query = query.where("created_at", "<=", params.to); + countQuery = countQuery.where("created_at", "<=", params.to); + } + + const [rows, countResult] = await Promise.all([ + query.orderBy("created_at", "desc").limit(limit).offset(offset), + countQuery.count("id as count").first(), + ]); + + return { + executions: rows.map((r: Record) => this.mapExecution(r)), + total: Number(countResult?.count ?? 0), + }; + } + + public async getExecution(id: string): Promise { + const db = getDatabase(); + const row = await db("automation_rule_executions").where({ id }).first(); + return row ? this.mapExecution(row) : null; + } + + // --------------------------------------------------------------------------- + // EXPORT + // --------------------------------------------------------------------------- + + public async exportRuleHistoryCsv(params: RuleHistoryQuery = {}): Promise { + const { versions } = await this.getRuleHistory({ ...params, limit: 10_000, offset: 0 }); + + const header = [ + "id", "rule_id", "version", "change_type", "changed_by", + "change_reason", "created_at", + ].join(","); + + const rows = versions.map((v) => + [ + v.id, + v.ruleId, + v.version, + v.changeType, + v.changedBy, + v.changeReason ?? "", + v.createdAt.toISOString(), + ] + .map((value) => `"${String(value).replace(/"/g, '""')}"`) + .join(",") + ); + + return [header, ...rows].join("\n"); + } + + public async exportExecutionHistoryCsv(params: ExecutionHistoryQuery = {}): Promise { + const { executions } = await this.getExecutionHistory({ ...params, limit: 10_000, offset: 0 }); + + const header = [ + "id", "rule_id", "rule_version", "triggered", "status", + "executed_by", "started_at", "completed_at", "duration_ms", "created_at", + ].join(","); + + const rows = executions.map((e) => + [ + e.id, + e.ruleId, + e.ruleVersion, + e.triggered, + e.status, + e.executedBy, + e.startedAt.toISOString(), + e.completedAt?.toISOString() ?? "", + e.durationMs ?? "", + e.createdAt.toISOString(), + ] + .map((value) => `"${String(value).replace(/"/g, '""')}"`) + .join(",") + ); + + return [header, ...rows].join("\n"); + } + + // --------------------------------------------------------------------------- + // SEARCH + // --------------------------------------------------------------------------- + + public async searchRuleHistory(params: { + q?: string; + ruleId?: string; + changedBy?: string; + changeType?: AutomationRuleChangeType; + from?: Date; + to?: Date; + limit?: number; + offset?: number; + } = {}): Promise<{ versions: AutomationRuleVersion[]; total: number }> { + const db = getDatabase(); + const limit = Math.min(params.limit ?? 100, 1000); + const offset = params.offset ?? 0; + + let query = db("automation_rule_versions"); + let countQuery = db("automation_rule_versions"); + + if (params.q) { + const pattern = `%${params.q}%`; + query = query.where((builder) => { + builder + .whereILike("change_reason", pattern) + .orWhereRaw("snapshot::text ILIKE ?", [pattern]); + }); + countQuery = countQuery.where((builder) => { + builder + .whereILike("change_reason", pattern) + .orWhereRaw("snapshot::text ILIKE ?", [pattern]); + }); + } + + if (params.ruleId) { + query = query.where("rule_id", params.ruleId); + countQuery = countQuery.where("rule_id", params.ruleId); + } + if (params.changedBy) { + query = query.where("changed_by", params.changedBy); + countQuery = countQuery.where("changed_by", params.changedBy); + } + if (params.changeType) { + query = query.where("change_type", params.changeType); + countQuery = countQuery.where("change_type", params.changeType); + } + if (params.from) { + query = query.where("created_at", ">=", params.from); + countQuery = countQuery.where("created_at", ">=", params.from); + } + if (params.to) { + query = query.where("created_at", "<=", params.to); + countQuery = countQuery.where("created_at", "<=", params.to); + } + + const [rows, countResult] = await Promise.all([ + query.orderBy("created_at", "desc").limit(limit).offset(offset), + countQuery.count("id as count").first(), + ]); + + return { + versions: rows.map((r: Record) => this.mapVersion(r)), + total: Number(countResult?.count ?? 0), + }; + } + + // --------------------------------------------------------------------------- + // HELPERS + // --------------------------------------------------------------------------- + + private async createVersionSnapshot( + rule: AutomationRule, + changedBy: string, + changeType: AutomationRuleChangeType, + changeReason: string | null + ): Promise { + const db = getDatabase(); + await db("automation_rule_versions").insert({ + id: crypto.randomUUID(), + rule_id: rule.id, + version: rule.version, + snapshot: JSON.stringify(this.ruleToSnapshot(rule)), + changed_by: changedBy, + change_type: changeType, + change_reason: changeReason, + created_at: new Date(), + }); + } + + private async logRuleAudit( + action: "alert.rule_created" | "alert.rule_updated" | "alert.rule_deleted", + rule: AutomationRule, + actor: { id: string; type?: "user" | "api_key" | "system" }, + before: Record | null, + after: Record | null + ): Promise { + await auditService.log({ + action, + actorId: actor.id, + actorType: actor.type ?? "user", + resourceType: "automation_rule", + resourceId: rule.id, + before, + after, + metadata: { + ruleName: rule.name, + assetCode: rule.assetCode, + version: rule.version, + status: rule.status, + }, + severity: action === "alert.rule_deleted" ? "warning" : "info", + }); + } + + private ruleToSnapshot(rule: AutomationRule): Record { + return { + id: rule.id, + name: rule.name, + description: rule.description, + assetCode: rule.assetCode, + conditions: rule.conditions, + logicOperator: rule.logicOperator, + actions: rule.actions, + status: rule.status, + ownerAddress: rule.ownerAddress, + cooldownSeconds: rule.cooldownSeconds, + version: rule.version, + }; + } + + private statusToChangeType(status: AutomationRuleStatus): AutomationRuleChangeType { + if (status === "active") return "activate"; + if (status === "inactive") return "deactivate"; + return "update"; + } + + private computeDiff( + from: Record, + to: Record + ): Record { + const diff: Record = {}; + const keys = new Set([...Object.keys(from), ...Object.keys(to)]); + + for (const key of keys) { + const fromValue = from[key]; + const toValue = to[key]; + if (JSON.stringify(fromValue) !== JSON.stringify(toValue)) { + diff[key] = { from: fromValue, to: toValue }; + } + } + + return diff; + } + + private mapRule(row: Record): AutomationRule { + return { + id: row.id as string, + name: row.name as string, + description: (row.description as string | null) ?? null, + assetCode: row.asset_code as string, + conditions: this.parseJson(row.conditions, []), + logicOperator: row.logic_operator as LogicOperator, + actions: this.parseJson(row.actions, []), + status: row.status as AutomationRuleStatus, + ownerAddress: row.owner_address as string, + cooldownSeconds: row.cooldown_seconds as number, + lastExecutedAt: (row.last_executed_at as Date | null) ?? null, + executionCount: Number(row.execution_count ?? 0), + version: row.version as number, + createdAt: row.created_at as Date, + updatedAt: row.updated_at as Date, + }; + } + + private mapVersion(row: Record): AutomationRuleVersion { + return { + id: row.id as string, + ruleId: row.rule_id as string, + version: row.version as number, + snapshot: this.parseJson(row.snapshot, {}), + changedBy: row.changed_by as string, + changeType: row.change_type as AutomationRuleChangeType, + changeReason: (row.change_reason as string | null) ?? null, + createdAt: row.created_at as Date, + }; + } + + private mapExecution(row: Record): AutomationRuleExecution { + return { + id: row.id as string, + ruleId: row.rule_id as string, + ruleVersion: row.rule_version as number, + inputMetrics: this.parseJson(row.input_metrics, {}), + conditionResults: this.parseJson(row.condition_results, []), + triggered: row.triggered as boolean, + actionsExecuted: this.parseJson(row.actions_executed, null), + actionResults: this.parseJson(row.action_results, null), + status: row.status as "completed" | "failed" | "partial", + errorMessage: (row.error_message as string | null) ?? null, + executedBy: row.executed_by as string, + startedAt: row.started_at as Date, + completedAt: (row.completed_at as Date | null) ?? null, + durationMs: row.duration_ms === null ? null : Number(row.duration_ms), + createdAt: row.created_at as Date, + }; + } + + private parseJson(value: unknown, defaultValue: T): T { + if (value === null || value === undefined) return defaultValue; + if (typeof value === "object") return value as T; + try { + return JSON.parse(value as string) as T; + } catch { + return defaultValue; + } + } +} + +export const automationRulesService = AutomationRulesService.getInstance(); diff --git a/backend/src/services/ruleEvaluator.service.ts b/backend/src/services/ruleEvaluator.service.ts index ec9b1ed8..3a833e64 100644 --- a/backend/src/services/ruleEvaluator.service.ts +++ b/backend/src/services/ruleEvaluator.service.ts @@ -42,6 +42,14 @@ export interface RuleEvaluationOutput { conditionResults: ConditionResult[]; previewMode: boolean; evaluatedAt: string; + executedBy?: string; + executionContext?: string; +} + +export interface EvaluationOptions { + previewMode?: boolean; + executedBy?: string; + executionContext?: string; } function evaluateCondition( @@ -83,8 +91,9 @@ export class RuleEvaluatorService { input: RuleEvaluationInput, metrics: Record, previousMetrics?: Record, - previewMode = false + options: boolean | EvaluationOptions = false ): RuleEvaluationOutput { + const opts: EvaluationOptions = typeof options === "boolean" ? { previewMode: options } : options; this.validateConditions(input.conditions); const conditionResults: ConditionResult[] = input.conditions.map((cond) => { @@ -114,12 +123,14 @@ export class RuleEvaluatorService { triggered, logicOperator: input.logicOperator, conditionResults, - previewMode, + previewMode: opts.previewMode ?? false, evaluatedAt: new Date().toISOString(), + executedBy: opts.executedBy ?? "system", + executionContext: opts.executionContext ?? "api", }; - if (!previewMode) { - this.persistEvaluationLog(result, metrics).catch((err) => + if (!result.previewMode) { + this.persistEvaluationLog(result, metrics, opts.executedBy, opts.executionContext).catch((err) => logger.error({ err }, "Failed to persist rule evaluation log") ); } @@ -131,10 +142,11 @@ export class RuleEvaluatorService { inputs: RuleEvaluationInput[], metrics: Record, previousMetrics?: Record, - previewMode = false + options: boolean | EvaluationOptions = false ): RuleEvaluationOutput[] { + const opts: EvaluationOptions = typeof options === "boolean" ? { previewMode: options } : options; return inputs.map((input) => - this.evaluate(input, metrics, previousMetrics, previewMode) + this.evaluate(input, metrics, previousMetrics, opts) ); } @@ -142,20 +154,58 @@ export class RuleEvaluatorService { ruleId?: string; assetCode?: string; triggered?: boolean; + executedBy?: string; + executionContext?: string; + from?: Date; + to?: Date; limit?: number; offset?: number; - } = {}): Promise { + } = {}): Promise<{ evaluations: RuleEvaluationOutput[]; total: number }> { const db = getDatabase(); - let query = db("rule_evaluator_logs").orderBy("evaluated_at", "desc"); + const limit = Math.min(params.limit ?? 100, 1000); + const offset = params.offset ?? 0; + + let query = db("rule_evaluator_logs"); + let countQuery = db("rule_evaluator_logs"); + + if (params.ruleId) { + query = query.where("rule_id", params.ruleId); + countQuery = countQuery.where("rule_id", params.ruleId); + } + if (params.assetCode) { + query = query.where("asset_code", params.assetCode); + countQuery = countQuery.where("asset_code", params.assetCode); + } + if (params.triggered !== undefined) { + query = query.where("triggered", params.triggered); + countQuery = countQuery.where("triggered", params.triggered); + } + if (params.executedBy) { + query = query.where("executed_by", params.executedBy); + countQuery = countQuery.where("executed_by", params.executedBy); + } + if (params.executionContext) { + query = query.where("execution_context", params.executionContext); + countQuery = countQuery.where("execution_context", params.executionContext); + } + if (params.from) { + query = query.where("evaluated_at", ">=", params.from); + countQuery = countQuery.where("evaluated_at", ">=", params.from); + } + if (params.to) { + query = query.where("evaluated_at", "<=", params.to); + countQuery = countQuery.where("evaluated_at", "<=", params.to); + } - if (params.ruleId) query = query.where("rule_id", params.ruleId); - if (params.assetCode) query = query.where("asset_code", params.assetCode); - if (params.triggered !== undefined) query = query.where("triggered", params.triggered); - if (params.limit) query = query.limit(params.limit); - if (params.offset) query = query.offset(params.offset); + const [rows, countResult] = await Promise.all([ + query.orderBy("evaluated_at", "desc").limit(limit).offset(offset), + countQuery.count("id as count").first(), + ]); - const rows = await query; - return rows.map((r: Record) => this.mapRow(r)); + return { + evaluations: rows.map((r: Record) => this.mapRow(r)), + total: Number(countResult?.count ?? 0), + }; } private validateConditions(conditions: RuleCondition[]): void { @@ -172,7 +222,10 @@ export class RuleEvaluatorService { private async persistEvaluationLog( result: RuleEvaluationOutput, - metrics: Record + metrics: Record, + executedBy?: string, + executionContext?: string, + metadata?: Record ): Promise { const db = getDatabase(); await db("rule_evaluator_logs").insert({ @@ -185,6 +238,9 @@ export class RuleEvaluatorService { triggered: result.triggered, logic_operator: result.logicOperator, preview_mode: result.previewMode, + executed_by: executedBy ?? "system", + execution_context: executionContext ?? "api", + metadata: metadata ? JSON.stringify(metadata) : "{}", evaluated_at: new Date(), }); } @@ -204,6 +260,8 @@ export class RuleEvaluatorService { conditionResults: evaluationResult?.conditionResults ?? [], previewMode: row.preview_mode as boolean, evaluatedAt: (row.evaluated_at as Date).toISOString(), + executedBy: (row.executed_by as string) ?? "system", + executionContext: (row.execution_context as string) ?? "api", }; } } diff --git a/backend/tests/services/automationRules.service.test.ts b/backend/tests/services/automationRules.service.test.ts new file mode 100644 index 00000000..86ec9a37 --- /dev/null +++ b/backend/tests/services/automationRules.service.test.ts @@ -0,0 +1,269 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { AutomationRulesService } from "../../src/services/automationRules.service.js"; + +vi.mock("../../src/utils/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +vi.mock("../../src/services/audit.service.js", () => ({ + auditService: { + log: vi.fn().mockResolvedValue({}), + }, +})); + +function createMockDb(rows: Record[] = []) { + const db: Record = {}; + let result = rows; + + db.where = vi.fn().mockReturnValue(db); + db.whereILike = vi.fn().mockReturnValue(db); + db.whereRaw = vi.fn().mockReturnValue(db); + db.orWhereILike = vi.fn().mockReturnValue(db); + db.orWhereRaw = vi.fn().mockReturnValue(db); + db.orderBy = vi.fn().mockReturnValue(db); + db.limit = vi.fn().mockReturnValue(db); + db.offset = vi.fn().mockResolvedValue(result); + db.insert = vi.fn().mockReturnValue(db); + db.update = vi.fn().mockReturnValue(db); + db.delete = vi.fn().mockResolvedValue(1); + db.count = vi.fn().mockReturnValue(db); + db.first = vi.fn().mockResolvedValue(null); + db.clone = vi.fn().mockReturnValue(db); + db.select = vi.fn().mockReturnValue(db); + db.groupBy = vi.fn().mockReturnValue(db); + db.raw = vi.fn((expr: string) => expr); + db.returning = vi.fn().mockResolvedValue(result); + + return { db, setResult: (r: Record[]) => { result = r; db.offset = vi.fn().mockResolvedValue(result); } }; +} + +vi.mock("../../src/database/connection.js", () => ({ + getDatabase: vi.fn(() => { + const { db } = createMockDb(); + return vi.fn(() => db) as any; + }), +})); + +describe("AutomationRulesService", () => { + let service: AutomationRulesService; + + beforeEach(() => { + (AutomationRulesService as any).instance = undefined; + service = AutomationRulesService.getInstance(); + }); + + const actor = { id: "user-1", type: "user" as const }; + + describe("createRule", () => { + it("creates a rule and returns it", async () => { + const mockDb = createMockDb([ + { + id: "rule-1", + name: "Test Rule", + description: null, + asset_code: "USDC", + conditions: "[]", + logic_operator: "AND", + actions: "[]", + status: "active", + owner_address: "owner-1", + cooldown_seconds: 3600, + last_executed_at: null, + execution_count: 0, + version: 1, + created_at: new Date(), + updated_at: new Date(), + }, + ]); + vi.mocked((await import("../../src/database/connection.js")).getDatabase).mockReturnValue( + vi.fn(() => mockDb.db) as any + ); + + const rule = await service.createRule( + { + name: "Test Rule", + assetCode: "USDC", + conditions: [], + logicOperator: "AND", + actions: [], + ownerAddress: "owner-1", + status: "active", + }, + actor + ); + + expect(rule.id).toBe("rule-1"); + expect(rule.name).toBe("Test Rule"); + expect(rule.version).toBe(1); + expect(mockDb.db.insert).toHaveBeenCalled(); + }); + }); + + describe("getRule", () => { + it("returns null when rule not found", async () => { + const mockDb = createMockDb([]); + mockDb.db.first.mockResolvedValue(null); + vi.mocked((await import("../../src/database/connection.js")).getDatabase).mockReturnValue( + vi.fn(() => mockDb.db) as any + ); + + const rule = await service.getRule("missing-id"); + expect(rule).toBeNull(); + }); + }); + + describe("listRules", () => { + it("returns paginated rules", async () => { + const mockDb = createMockDb([ + { + id: "rule-1", + name: "Rule 1", + description: null, + asset_code: "USDC", + conditions: "[]", + logic_operator: "AND", + actions: "[]", + status: "active", + owner_address: "owner-1", + cooldown_seconds: 3600, + last_executed_at: null, + execution_count: 0, + version: 1, + created_at: new Date(), + updated_at: new Date(), + }, + ]); + mockDb.db.first.mockResolvedValue({ count: "1" }); + vi.mocked((await import("../../src/database/connection.js")).getDatabase).mockReturnValue( + vi.fn(() => mockDb.db) as any + ); + + const result = await service.listRules({ ownerAddress: "owner-1" }); + expect(result.rules).toHaveLength(1); + expect(result.total).toBe(1); + }); + }); + + describe("updateRule", () => { + it("returns null when rule not found", async () => { + const mockDb = createMockDb([]); + mockDb.db.first.mockResolvedValue(null); + vi.mocked((await import("../../src/database/connection.js")).getDatabase).mockReturnValue( + vi.fn(() => mockDb.db) as any + ); + + const rule = await service.updateRule("missing-id", { name: "New Name" }, actor); + expect(rule).toBeNull(); + }); + }); + + describe("deleteRule", () => { + it("returns false when rule not found", async () => { + const mockDb = createMockDb([]); + mockDb.db.first.mockResolvedValue(null); + vi.mocked((await import("../../src/database/connection.js")).getDatabase).mockReturnValue( + vi.fn(() => mockDb.db) as any + ); + + const deleted = await service.deleteRule("missing-id", actor); + expect(deleted).toBe(false); + }); + }); + + describe("getRuleHistory", () => { + it("returns paginated history", async () => { + const mockDb = createMockDb([ + { + id: "version-1", + rule_id: "rule-1", + version: 1, + snapshot: "{}", + changed_by: "user-1", + change_type: "create", + change_reason: null, + created_at: new Date(), + }, + ]); + mockDb.db.first.mockResolvedValue({ count: "1" }); + vi.mocked((await import("../../src/database/connection.js")).getDatabase).mockReturnValue( + vi.fn(() => mockDb.db) as any + ); + + const result = await service.getRuleHistory({ ruleId: "rule-1" }); + expect(result.versions).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.versions[0].changeType).toBe("create"); + }); + }); + + describe("getExecutionHistory", () => { + it("returns paginated execution history", async () => { + const mockDb = createMockDb([ + { + id: "exec-1", + rule_id: "rule-1", + rule_version: 1, + input_metrics: "{}", + condition_results: "[]", + triggered: true, + actions_executed: null, + action_results: null, + status: "completed", + error_message: null, + executed_by: "system", + started_at: new Date(), + completed_at: new Date(), + duration_ms: 100, + created_at: new Date(), + }, + ]); + mockDb.db.first.mockResolvedValue({ count: "1" }); + vi.mocked((await import("../../src/database/connection.js")).getDatabase).mockReturnValue( + vi.fn(() => mockDb.db) as any + ); + + const result = await service.getExecutionHistory({ ruleId: "rule-1" }); + expect(result.executions).toHaveLength(1); + expect(result.total).toBe(1); + expect(result.executions[0].triggered).toBe(true); + }); + }); + + describe("searchRuleHistory", () => { + it("searches by query string", async () => { + const mockDb = createMockDb([ + { + id: "version-1", + rule_id: "rule-1", + version: 1, + snapshot: "{}", + changed_by: "user-1", + change_type: "create", + change_reason: null, + created_at: new Date(), + }, + ]); + mockDb.db.first.mockResolvedValue({ count: "1" }); + vi.mocked((await import("../../src/database/connection.js")).getDatabase).mockReturnValue( + vi.fn(() => mockDb.db) as any + ); + + const result = await service.searchRuleHistory({ q: "create" }); + expect(result.versions).toHaveLength(1); + expect(mockDb.db.where).toHaveBeenCalled(); + }); + }); + + describe("compareVersions", () => { + it("returns null when versions not found", async () => { + const mockDb = createMockDb([]); + mockDb.db.first.mockResolvedValue(null); + vi.mocked((await import("../../src/database/connection.js")).getDatabase).mockReturnValue( + vi.fn(() => mockDb.db) as any + ); + + const comparison = await service.compareVersions("rule-1", 1, 2); + expect(comparison).toBeNull(); + }); + }); +}); diff --git a/backend/tests/services/ruleEvaluator.service.test.ts b/backend/tests/services/ruleEvaluator.service.test.ts index 764a3f18..f1294258 100644 --- a/backend/tests/services/ruleEvaluator.service.test.ts +++ b/backend/tests/services/ruleEvaluator.service.test.ts @@ -15,6 +15,7 @@ vi.mock("../../src/database/connection.js", () => ({ b.first = vi.fn().mockResolvedValue(null); b.limit = vi.fn().mockReturnValue(b); b.offset = vi.fn().mockResolvedValue([]); + b.count = vi.fn().mockReturnValue(b); const fn = (_t: string) => b; return fn; }), @@ -169,9 +170,27 @@ describe("RuleEvaluatorService", () => { }); describe("getEvaluationHistory", () => { - it("returns empty array when no history exists", async () => { + it("returns empty paginated result when no history exists", async () => { const history = await service.getEvaluationHistory(); - expect(history).toEqual([]); + expect(history).toEqual({ evaluations: [], total: 0 }); + }); + }); + + describe("actor tracking", () => { + it("includes executedBy and executionContext in evaluation output", () => { + const result = service.evaluate( + { + ruleName: "actor-test", + assetCode: "USDC", + conditions: [{ field: "price", operator: "gt", value: 100 }], + logicOperator: "AND", + }, + { price: 150 }, + undefined, + { executedBy: "user-1", executionContext: "manual" } + ); + expect(result.executedBy).toBe("user-1"); + expect(result.executionContext).toBe("manual"); }); }); }); diff --git a/package-lock.json b/package-lock.json index fa7aaa3b..5dc8fb09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -483,77 +483,6 @@ "node": ">=6.0.0" } }, - "node_modules/@asamuzakjp/css-color": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.5.tgz", - "integrity": "sha512-8cMAA1bE66Mb/tfmkhcfJLjEPgyT7SSy6lW6id5XL113ai1ky76d/1L27sGnXCMsLfq66DInAU3OzuahB4lu9Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@csstools/css-calc": "^3.1.1", - "@csstools/css-color-parser": "^4.0.2", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.2.7" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.0.tgz", - "integrity": "sha512-sr8xPKE25m6vJVcrdn6NxtC0fVfuPowbscLypegRgOm0yXSqr5JNHCAY3hnusdJ7HRBW04j6Ip4khvHU778DuQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "optional": true, - "peer": true, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.6.tgz", - "integrity": "sha512-Tgmk6EQM0nc9xvp7sEHRVavbknhb/vGKht+04yAT3t5KQwZ02CSobCtcFgaHH04ZrjD1BhEKNA8tRhzFV20gkA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.7" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.0.tgz", - "integrity": "sha512-sr8xPKE25m6vJVcrdn6NxtC0fVfuPowbscLypegRgOm0yXSqr5JNHCAY3hnusdJ7HRBW04j6Ip4khvHU778DuQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "optional": true, - "peer": true, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -875,173 +804,6 @@ "node": ">=18" } }, - "node_modules/@bramus/specificity": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "css-tree": "^3.0.0" - }, - "bin": { - "specificity": "bin/cli.js" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", - "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "optional": true, - "peer": true, - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@csstools/css-calc": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", - "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", - "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.1.1" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", - "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "optional": true, - "peer": true, - "peerDependencies": { - "css-tree": "^3.2.1" - }, - "peerDependenciesMeta": { - "css-tree": { - "optional": true - } - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=20.19.0" - } - }, "node_modules/@discordjs/builders": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", @@ -1257,7 +1019,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -1275,7 +1036,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1293,7 +1053,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1311,7 +1070,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -1329,7 +1087,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -1347,7 +1104,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -1365,7 +1121,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1383,7 +1138,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1401,7 +1155,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1419,7 +1172,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1437,7 +1189,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1455,7 +1206,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1473,7 +1223,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1491,7 +1240,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1509,7 +1257,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1527,7 +1274,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1545,7 +1291,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -1563,7 +1308,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1581,7 +1325,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1599,7 +1342,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1617,7 +1359,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1635,7 +1376,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -1653,7 +1393,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -1671,7 +1410,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -1689,7 +1427,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -1707,7 +1444,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -5747,18 +5483,6 @@ "node": ">=12.0.0" } }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "require-from-string": "^2.0.2" - } - }, "node_modules/bignumber.js": { "version": "9.3.1", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", @@ -6297,22 +6021,6 @@ "node": ">= 8" } }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -6632,22 +6340,6 @@ "node": ">= 12" } }, - "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, "node_modules/dateformat": { "version": "4.6.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", @@ -8313,41 +8005,6 @@ "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", "license": "MIT" }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/html-encoding-sniffer/node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -9152,81 +8809,6 @@ "node": ">=12.0.0" } }, - "node_modules/jsdom": { - "version": "29.0.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", - "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@asamuzakjp/css-color": "^5.0.1", - "@asamuzakjp/dom-selector": "^7.0.3", - "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.1", - "@exodus/bytes": "^1.15.0", - "css-tree": "^3.2.1", - "data-urls": "^7.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.7", - "parse5": "^8.0.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.1", - "undici": "^7.24.5", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.1", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jsdom/node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/jsdom/node_modules/lru-cache": { - "version": "11.3.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.0.tgz", - "integrity": "sha512-sr8xPKE25m6vJVcrdn6NxtC0fVfuPowbscLypegRgOm0yXSqr5JNHCAY3hnusdJ7HRBW04j6Ip4khvHU778DuQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "optional": true, - "peer": true, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -9485,280 +9067,6 @@ ], "license": "MIT" }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "optional": true, - "peer": true, - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -9960,15 +9268,6 @@ "node": ">= 0.4" } }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0", - "optional": true, - "peer": true - }, "node_modules/memoizerific": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/memoizerific/-/memoizerific-1.11.3.tgz", @@ -10554,21 +9853,6 @@ "node": ">=6" } }, - "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -13080,21 +12364,6 @@ "node": ">=16" } }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -13232,18 +12501,6 @@ "node": ">=14.17" } }, - "node_modules/undici": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", - "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=20.18.1" - } - }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -14136,18 +13393,6 @@ "node": ">= 8" } }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "engines": { - "node": ">=20" - } - }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", @@ -14169,55 +13414,6 @@ "node": ">=18" } }, - "node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/whatwg-url/node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",