From a61902dd764879bac3cacb0257283bf5e8a99085 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 12 Mar 2026 11:57:03 +0100 Subject: [PATCH 1/4] chore: add instrumentation Signed-off-by: William Phetsinorath --- apps/server-nestjs/package.json | 27 +- apps/server-nestjs/src/instrumentation.ts | 30 + apps/server-nestjs/src/main.ts | 4 +- docker/docker-compose.local.yml | 18 + pnpm-lock.yaml | 2068 +++++++++++++++++++-- 5 files changed, 1982 insertions(+), 165 deletions(-) create mode 100644 apps/server-nestjs/src/instrumentation.ts diff --git a/apps/server-nestjs/package.json b/apps/server-nestjs/package.json index 0a8f1c0c9..a1641059c 100644 --- a/apps/server-nestjs/package.json +++ b/apps/server-nestjs/package.json @@ -44,6 +44,16 @@ "@nestjs/core": "^11.1.16", "@nestjs/platform-express": "^11.1.16", "@prisma/client": "^6.19.2", + "@nestjs/schedule": "^5.0.1", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/auto-instrumentations-node": "^0.70.1", + "@opentelemetry/exporter-metrics-otlp-proto": "^0.213.0", + "@opentelemetry/exporter-trace-otlp-proto": "^0.213.0", + "@opentelemetry/instrumentation-nestjs-core": "^0.58.0", + "@opentelemetry/instrumentation-pino": "^0.59.0", + "@opentelemetry/sdk-metrics": "^2.5.1", + "@opentelemetry/sdk-node": "^0.212.0", + "@opentelemetry/sdk-trace-node": "^2.5.1", "@ts-rest/core": "^3.52.1", "@ts-rest/fastify": "^3.52.1", "@ts-rest/open-api": "^3.52.1", @@ -95,23 +105,6 @@ "vite-node": "^2.1.9", "vitest": "^2.1.9" }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coverageDirectory": "../coverage", - "testEnvironment": "node" - }, "publishConfig": { "tag": "latest" } diff --git a/apps/server-nestjs/src/instrumentation.ts b/apps/server-nestjs/src/instrumentation.ts new file mode 100644 index 000000000..b7759ec9a --- /dev/null +++ b/apps/server-nestjs/src/instrumentation.ts @@ -0,0 +1,30 @@ +import { NodeSDK } from '@opentelemetry/sdk-node' +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node' +import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' +import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core' +import { PinoInstrumentation } from '@opentelemetry/instrumentation-pino' +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto' +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto' + +function createSdk() { + return new NodeSDK({ + traceExporter: new OTLPTraceExporter({}), + metricReader: new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter(), + }), + instrumentations: [ + getNodeAutoInstrumentations(), + new NestInstrumentation(), + new PinoInstrumentation(), + ], + serviceName: 'cloud-pi-native-console', + }) +} + +export function instrument() { + const sdk = createSdk() + sdk.start() + process.on('SIGTERM', () => { + sdk.shutdown() + }) +} diff --git a/apps/server-nestjs/src/main.ts b/apps/server-nestjs/src/main.ts index 532b344e1..2ef5772bc 100644 --- a/apps/server-nestjs/src/main.ts +++ b/apps/server-nestjs/src/main.ts @@ -1,6 +1,6 @@ import { NestFactory } from '@nestjs/core' import { Logger } from 'nestjs-pino' - +import { instrument } from './instrumentation' import { ConfigurationService } from './cpin-module/infrastructure/configuration/configuration.service' import { MainModule } from './main.module' @@ -11,4 +11,6 @@ async function bootstrap() { const config = app.get(ConfigurationService) await app.listen(config.port ?? 0) } + +instrument() bootstrap() diff --git a/docker/docker-compose.local.yml b/docker/docker-compose.local.yml index 1376f2fab..b90ea8bf0 100644 --- a/docker/docker-compose.local.yml +++ b/docker/docker-compose.local.yml @@ -116,6 +116,24 @@ services: - dso-network attach: false + jaeger: + restart: unless-stopped + image: jaegertracing/all-in-one:1.76.0 + container_name: dso-console_jaeger + environment: + COLLECTOR_ZIPKIN_HOST_PORT: :9411 + ports: + - 16686:16686 + - 4317:4317 + - 4318:4318 + - 14250:14250 + - 14268:14268 + - 14269:14269 + - 9411:9411 + networks: + - dso-network + attach: false + networks: dso-network: driver: bridge diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 00578e9e7..72e6796a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -416,6 +416,36 @@ importers: '@nestjs/platform-express': specifier: ^11.1.16 version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/schedule': + specifier: ^5.0.1 + version: 5.0.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.0 + '@opentelemetry/auto-instrumentations-node': + specifier: ^0.70.1 + version: 0.70.1(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0)) + '@opentelemetry/exporter-metrics-otlp-proto': + specifier: ^0.213.0 + version: 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-proto': + specifier: ^0.213.0 + version: 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-nestjs-core': + specifier: ^0.58.0 + version: 0.58.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-pino': + specifier: ^0.59.0 + version: 0.59.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': + specifier: ^2.5.1 + version: 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-node': + specifier: ^0.212.0 + version: 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-node': + specifier: ^2.5.1 + version: 2.6.0(@opentelemetry/api@1.9.0) '@prisma/client': specifier: ^6.19.2 version: 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) @@ -2406,6 +2436,15 @@ packages: vue: ^3.5.27 vue-router: ^4.6.4 + '@grpc/grpc-js@1.14.3': + resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.0': + resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==} + engines: {node: '>=6'} + hasBin: true + '@himenon/argocd-typescript-openapi@1.2.2': resolution: {integrity: sha512-xS8HAzhRSQXqMfD779EgbZmPcgkaNW8Hf2anrwvvPYb9Tt0vP9CtzOqj+8GYSOUQqgcRFejmCR9WoAgZJamB1g==} engines: {pnpm: '>=8'} @@ -2617,6 +2656,9 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@keycloak/keycloak-admin-client@26.5.5': resolution: {integrity: sha512-ZYP1Z+4qZ+vChNKWI+g1X08F2gCpZEWRlEMjwF03xet7bB5j5898nSJNFT1g6XIqVslHq15R8pt55fcULpRuvw==} engines: {node: '>=18'} @@ -2717,6 +2759,12 @@ packages: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 + '@nestjs/schedule@5.0.1': + resolution: {integrity: sha512-kFoel84I4RyS2LNPH6yHYTKxB16tb3auAEciFuc788C3ph6nABkUfzX5IE+unjVaRX+3GuruJwurNepMlHXpQg==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/schematics@11.0.9': resolution: {integrity: sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==} peerDependencies: @@ -2765,6 +2813,555 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@opentelemetry/api-logs@0.212.0': + resolution: {integrity: sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api-logs@0.213.0': + resolution: {integrity: sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/auto-instrumentations-node@0.70.1': + resolution: {integrity: sha512-r8BKs0rHtBAzZViPIuzSD2eh65fOPau0NqVsca2sACuZ6LFGu6a+QMhqq7skXz+/OqKwFr/7/b6VsaNMS+zZpQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.4.1 + '@opentelemetry/core': ^2.0.0 + + '@opentelemetry/configuration@0.212.0': + resolution: {integrity: sha512-D8sAY6RbqMa1W8lCeiaSL2eMCW2MF87QI3y+I6DQE1j+5GrDMwiKPLdzpa/2/+Zl9v1//74LmooCTCJBvWR8Iw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/context-async-hooks@2.5.1': + resolution: {integrity: sha512-MHbu8XxCHcBn6RwvCt2Vpn1WnLMNECfNKYB14LI5XypcgH4IE0/DiVifVR9tAkwPMyLXN8dOoPJfya3IryLQVw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/context-async-hooks@2.6.0': + resolution: {integrity: sha512-L8UyDwqpTcbkIK5cgwDRDYDoEhQoj8wp8BwsO19w3LB1Z41yEQm2VJyNfAi9DrLP/YTqXqWpKHyZfR9/tFYo1Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.5.1': + resolution: {integrity: sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.6.0': + resolution: {integrity: sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-grpc@0.212.0': + resolution: {integrity: sha512-/0bk6fQG+eSFZ4L6NlckGTgUous/ib5+OVdg0x4OdwYeHzV3lTEo3it1HgnPY6UKpmX7ki+hJvxjsOql8rCeZA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-http@0.212.0': + resolution: {integrity: sha512-JidJasLwG/7M9RTxV/64xotDKmFAUSBc9SNlxI32QYuUMK5rVKhHNWMPDzC7E0pCAL3cu+FyiKvsTwLi2KqPYw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-proto@0.212.0': + resolution: {integrity: sha512-RpKB5UVfxc7c6Ta1UaCrxXDTQ0OD7BCGT66a97Q5zR1x3+9fw4dSaiqMXT/6FAWj2HyFbem6Rcu1UzPZikGTWQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-grpc@0.212.0': + resolution: {integrity: sha512-/6Gqf9wpBq22XsomR1i0iPGnbQtCq2Vwnrq5oiDPjYSqveBdK1jtQbhGfmpK2mLLxk4cPDtD1ZEYdIou5K8EaA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.212.0': + resolution: {integrity: sha512-8hgBw3aTTRpSTkU4b9MLf/2YVLnfWp+hfnLq/1Fa2cky+vx6HqTodo+Zv1GTIrAKMOOwgysOjufy0gTxngqeBg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.213.0': + resolution: {integrity: sha512-yw3fTIw4KQIRXC/ZyYQq5gtA3Ogfdfz/g5HVgleobQAcjUUE8Nj3spGMx8iQPp+S+u6/js7BixufRkXhzLmpJA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.212.0': + resolution: {integrity: sha512-C7I4WN+ghn3g7SnxXm2RK3/sRD0k/BYcXaK6lGU3yPjiM7a1M25MLuM6zY3PeVPPzzTZPfuS7+wgn/tHk768Xw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.213.0': + resolution: {integrity: sha512-geHF+zZaDb0/WRkJTxR8o8dG4fCWT/Wq7HBdNZCxwH5mxhwRi/5f37IDYH7nvU+dwU6IeY4Pg8TPI435JCiNkg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-prometheus@0.212.0': + resolution: {integrity: sha512-hJFLhCJba5MW5QHexZMHZdMhBfNqNItxOsN0AZojwD1W2kU9xM+BEICowFGJFo/vNV+I2BJvTtmuKafeDSAo7Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.212.0': + resolution: {integrity: sha512-9xTuYWp8ClBhljDGAoa0NSsJcsxJsC9zCFKMSZJp1Osb9pjXCMRdA6fwXtlubyqe7w8FH16EWtQNKx/FWi+Ghw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-http@0.212.0': + resolution: {integrity: sha512-v/0wMozNoiEPRolzC4YoPo4rAT0q8r7aqdnRw3Nu7IDN0CGFzNQazkfAlBJ6N5y0FYJkban7Aw5WnN73//6YlA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.212.0': + resolution: {integrity: sha512-d1ivqPT0V+i0IVOOdzGaLqonjtlk5jYrW7ItutWzXL/Mk+PiYb59dymy/i2reot9dDnBFWfrsvxyqdutGF5Vig==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.213.0': + resolution: {integrity: sha512-six3vPq3sL+ge1iZOfKEg+RHuFQhGb8ZTdlvD234w/0gi8ty/qKD46qoGpKvM3amy5yYunWBKiFBW47WaVS26w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-zipkin@2.5.1': + resolution: {integrity: sha512-Me6JVO7WqXGXsgr4+7o+B7qwKJQbt0c8WamFnxpkR43avgG9k/niTntwCaXiXUTjonWy0+61ZuX6CGzj9nn8CQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-amqplib@0.59.0': + resolution: {integrity: sha512-xscSgOJA+GHphESDZxBHNk/zjNaEgoeufMwmiqYdL+qM27Xw3BbR9vN6Ucbq9dW6Y+oYUPgTTj17qf+Za4+uzg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-aws-lambda@0.64.0': + resolution: {integrity: sha512-vYhM/a8fG34/Dl/Q9gfv5Ih3OFPgqeyn79S8FN+Xs/QZw6h6L8a1lDa3CyigyicOXLCmVIM7Fc9vFD4BGqgGLA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-aws-sdk@0.67.0': + resolution: {integrity: sha512-btpwJnZ2RBXDh/pTpfVpInpBu9Pedi+lbLKbt3naB344SggbbYnIdT7u8EzmGIApWi9EV91vw7hm896I7nESQA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-bunyan@0.57.0': + resolution: {integrity: sha512-W4zLz1Y9ptCsdL+QMXR7xQaBHkJivLBmVlLCjUe23rX4V8E65fGAtlIJSKTKAfz4aEgtWgQAGMdkeqACwG0Caw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-cassandra-driver@0.57.0': + resolution: {integrity: sha512-xLwrK+XnN32IB5i6t/a2j+SVdjlq/BIgjpVRkke4HAsKjoSMy1GeSI+ZOiJffRLFb4MojcvH4RG2+nEg1uC6Zg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-connect@0.55.0': + resolution: {integrity: sha512-UfGw7ubKKZBoTRjxi5KlfeECEaXZinS20RdRNlZE5tVF+O17hJOnrcGwAoQAHp6eYmxI2jW9IQ4t6450gnNF9g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-cucumber@0.28.0': + resolution: {integrity: sha512-kim+bRxu4LZqKEyF2SgO01tgG88W+/iYltyP1XjT31FIXzlBjzQpwtSLLM8byayO85mcZIBha54WSNFDLM/7qQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-dataloader@0.29.0': + resolution: {integrity: sha512-220WjRb1G1UiAKbVblSMxwxxFdpyB4wj1XYIO9BJs5r62Azj2dL5fyZiXK3/WO6wB3uLul9R946iKI1bpPxktQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-dns@0.55.0': + resolution: {integrity: sha512-cfWLaFi22V+sQrKY7t6QroYzT3kO9m3PpkN1OXYmuCyfwxQaXOVlF8NSAHtua/RQYw0aQl+2fe6JOWyJdEZiwA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-express@0.60.0': + resolution: {integrity: sha512-KghHCDqKq0D7iuPIVCuPSXut5WVAI6uwKcPrhwTUJL5VE2LC18id2vKoiAm1V8XvVlgIGAiECtEvbrFwkTCj3g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fastify@0.56.0': + resolution: {integrity: sha512-zotOPoZsWtMF47BjottK23XaaBSmVuwG5D/R3FlGfAAwMNFoDR3IY1OGO9v9KfOU/1/xDVkxsQ22NFfu9lE8aA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fs@0.31.0': + resolution: {integrity: sha512-C7tdXGDnkMgLVlE79VSekB+Y+P345zKUigvFMs5M7U0GIYA8ERx3FS0aAcY/ICIq9YwRmH2uuMb++Br5M2vNUg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-generic-pool@0.55.0': + resolution: {integrity: sha512-7hWiyLbEX/dIS4LZy/h8VaAQPs8oBeEqsrysDWbos0b9PF414L6Rsbi2um/omtxIs+GTvsbuqDscWigeaxyWdA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-graphql@0.60.0': + resolution: {integrity: sha512-XPATrmxAd2tFCsYbJ3eVIXt+gyvMKjc36QQuQxjtssMnAbw006Le9b5lKs7WXik7ItOpM1exATi1aDdOcCjRRg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-grpc@0.212.0': + resolution: {integrity: sha512-r1t7LNKWVhSQMUrBdDJtooFmmLZ93kGuFixqeXPoUP8W+chJCxhey9l0c0+L3xriNdyB7TzvkKHhPXUDevgVEA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-hapi@0.58.0': + resolution: {integrity: sha512-reuRApR2KHm2VsfyDgsrLhNE+IOy4uIU6n3oMjUleReHacEEZmf4vXxdt4/qcmJ6GoUXnRN2AOu3s5N3pMrgYA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-http@0.212.0': + resolution: {integrity: sha512-t2nt16Uyv9irgR+tqnX96YeToOStc3X5js7Ljn3EKlI2b4Fe76VhMkTXtsTQ0aId6AsYgefrCRnXSCo/Fn/vww==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-ioredis@0.60.0': + resolution: {integrity: sha512-R+nnbPD9l2ruzu248qM3YDWzpdmWVaFFFv08lQqsc0EP4pT/B1GGUg06/tHOSo3L5njB2eejwyzpkvJkjaQEMA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-kafkajs@0.21.0': + resolution: {integrity: sha512-lkLrILnKGO7SHw1xPJnuGx2S4XwbKmQiJyzUGuEImRoU/6Gj0Nka0lkbeRd4ANN20dxr/mLdXIsUsk6DzTrX6A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-knex@0.56.0': + resolution: {integrity: sha512-pKqtY5lbAQ70MC5K/BJeAA1t2gAUlRBZBAJ5ergRUNs5jw8zbdOXEZOLztiuNvQqD2z4a9N0Tkde9JMFm2pKMQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-koa@0.60.0': + resolution: {integrity: sha512-UOmu2y2LHgPzKsm9xd0sCQJimr11YP4MKFc190Do1ufd8qds7Zd5BI3f6TudqYhH9dUIhojsQyUaS6K4nv5FsQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/instrumentation-lru-memoizer@0.56.0': + resolution: {integrity: sha512-vXtOValhKRgWA9tLAiTU3P37Q31OveRuM2N5iLSVHl4GzkMBQ5p50A9kSKvt5gReL6BzFDXPCM9ItJiAhSS2KQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-memcached@0.55.0': + resolution: {integrity: sha512-kdhW/j5X+vNCAvHVc50PZfvE7diUScg1ZkBaNFRygY3Z6IUjgPLR0luWQMDPSFun6AVo1HaMDPxbUqJrot6qrA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongodb@0.65.0': + resolution: {integrity: sha512-hOAJRs5vrY7fZolSYUXmf29Y+HFDHWrek0DeLq82uwMPjPSda7h6oumQnqEX5olzw357q/QG39/uJdkclJ/JUg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongoose@0.58.0': + resolution: {integrity: sha512-3L0Fqo1y2oreISFPWaqdt/bg3NhLgrkn5U/E/9RNG1QaM81drTMBCHseMY1q8SlejjE43ZWOy+0KbmRBlUPJ+g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql2@0.58.0': + resolution: {integrity: sha512-EubjV1XZb7XHrENqF7TW2lnah+KN0LddMneKNAB8PjGVKL5lJkVV/vhJ6EIcUNn9nCWmAwZ3GRcFVEDKCnyXfQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql@0.58.0': + resolution: {integrity: sha512-wZDrBCL3WfJclV6KywWVV3/B2ZiUYmDQdgyu3pq4jK/5qSfoDmezHzT/Nayln5MVVWMAGXIMLrCj8BKa6jaKQQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-nestjs-core@0.58.0': + resolution: {integrity: sha512-0lE9oW8j6nmvBHJoOxIQgKzMQQYNfX1nhiWZdXD0sNAMFsWBtvECWS7NAPSroKrEP53I04TcHCyyhcK4I9voXg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-net@0.56.0': + resolution: {integrity: sha512-h69x7U6f86mP3gGWWTaMkQZk0K3tBvpVMIU7E0q2kkVw6eZ5TqFm9rkaEy38moQmixiDFQ9j/2/cwxG9P7ZEeA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-openai@0.10.0': + resolution: {integrity: sha512-0lV2zxge2mMaruVCw/bmypWVu+aJ76rc0HBvAVFCPUI3zzJdgBZJZafGIHZ1IB2F6VvrDFL+JstEnle6V8brvA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-oracledb@0.37.0': + resolution: {integrity: sha512-OzMghtAEAEkXlkUrZI4QcXSZq0MILeU6WC0/N5+1MSkuIkruIeaRw99/RtyS2of8vlPDa8XbbXl32Q1RM3wSyg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pg@0.64.0': + resolution: {integrity: sha512-NbfB/rlfsRI3zpTjnbvJv3qwuoGLsN8FxR/XoI+ZTn1Rs62x1IenO+TSSvk4NO+7FlXpd2MiOe8LT/oNbydHGA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pino@0.58.0': + resolution: {integrity: sha512-rgy+tA7cDjuSq6dXAO40OiYP25azIDHMBtxG3RzSmCBVEYdjggl6btyuLVasX6VkOOhP2gf6PBuLMNxVwaIqAw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pino@0.59.0': + resolution: {integrity: sha512-IgImVFtWjfMmqxc0NIe3iSjp+J3Asf9lLX8reouUFUk3Aa/qJQO5PEvOtO3sNQtJBkC9bAd1OQdFaFWSFQc03g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-redis@0.60.0': + resolution: {integrity: sha512-Ea/GffmmzIVHc9geaMjT94IR7poVZzIv4Kk/Lw0tbxGD3cBYcMUsLFVajKxpZsE1NRCECFpidAWeifCIKD0inw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-restify@0.57.0': + resolution: {integrity: sha512-kO6MsZFU+RdXOKhsKw8SOSBYGYCdFSlza+mpBQRl1DQmveZcnidchv4V5JQPtNgHxCGH+1n3hDpLdxdGUbJPNA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-router@0.56.0': + resolution: {integrity: sha512-PHECDGQElLazI/QbHU16C5m9fDC7DGJk+jLIwO5ca6bcp7bXhUPPUTT78l7da2pDsrz4mhv5ytYNZmBbW/Q3rA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-runtime-node@0.25.0': + resolution: {integrity: sha512-XaCmwBSui5KeTn8M6OzaEn1rEsNWtUkjuc1ylg0tqQTLHibNQ0n7f8v4zdF6x/nBV1OnsiYlN8RLHauGemv/TA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-socket.io@0.59.0': + resolution: {integrity: sha512-71DnM/FEqH0PjvU2uZvzWJeaGyVIy3rJKk8rZrxg/aS2QT3qLGb+UPL/B+1vOw4pzDPn4papLTSMpLVF9G8uvw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-tedious@0.31.0': + resolution: {integrity: sha512-HoF2EtcyP3JR4R3jLPHohZ9lFcj1QLJyGmFfLKDTvUUjPiFuK4XZ6L1OV9HhaqvN0xY+tWKfNdCPS3r33rd0Xw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-undici@0.22.0': + resolution: {integrity: sha512-yb6vEWUPOrD5i7yR1XceEEqiVHbMgr5YnUPnom5eQVCjvrTkEVswyrf9i+vvJR+28wrNqILIIphWgOOx6BjnTQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + + '@opentelemetry/instrumentation-winston@0.56.0': + resolution: {integrity: sha512-ITIA0Qe61CQ6FQU/bN23pNBvJ+5U0ofoASMOOYrODtXyV9wI267AigNTTwDmv2Myt8dPEFvvVFJZKhiZLIpehA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.212.0': + resolution: {integrity: sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.213.0': + resolution: {integrity: sha512-3i9NdkET/KvQomeh7UaR/F4r9P25Rx6ooALlWXPIjypcEOUxksCmVu0zA70NBJWlrMW1rPr/LRidFAflLI+s/w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.212.0': + resolution: {integrity: sha512-HoMv5pQlzbuxiMS0hN7oiUtg8RsJR5T7EhZccumIWxYfNo/f4wFc7LPDfFK6oHdG2JF/+qTocfqIHoom+7kLpw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.213.0': + resolution: {integrity: sha512-MegxAP1/n09Ob2dQvY5NBDVjAFkZRuKtWKxYev1R2M8hrsgXzQGkaMgoEKeUOyQ0FUyYcO29UOnYdQWmWa0PXg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-grpc-exporter-base@0.212.0': + resolution: {integrity: sha512-YidOSlzpsun9uw0iyIWrQp6HxpMtBlECE3tiHGAsnpEqJWbAUWcMnIffvIuvTtTQ1OyRtwwaE79dWSQ8+eiB7g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.212.0': + resolution: {integrity: sha512-bj7zYFOg6Db7NUwsRZQ/WoVXpAf41WY2gsd3kShSfdpZQDRKHWJiRZIg7A8HvWsf97wb05rMFzPbmSHyjEl9tw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.213.0': + resolution: {integrity: sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/propagator-b3@2.5.1': + resolution: {integrity: sha512-AU6sZgunZrZv/LTeHP+9IQsSSH5p3PtOfDPe8VTdwYH69nZCfvvvXehhzu+9fMW2mgJMh5RVpiH8M9xuYOu5Dg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-jaeger@2.5.1': + resolution: {integrity: sha512-8+SB94/aSIOVGDUPRFSBRHVUm2A8ye1vC6/qcf/D+TF4qat7PC6rbJhRxiUGDXZtMtKEPM/glgv5cBGSJQymSg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/redis-common@0.38.2': + resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==} + engines: {node: ^18.19.0 || >=20.6.0} + + '@opentelemetry/resource-detector-alibaba-cloud@0.33.3': + resolution: {integrity: sha512-Ep3LDWALU+wCgGzAa1rgFXh3TObahN7HaQZntAeVQnnNhZ3VSXnBniRGeSCTIRvRr7YdZvq+G+rstixtAN5Ugw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-aws@2.13.0': + resolution: {integrity: sha512-ZPCn7gZhGqUYUoD+RCHIlayoHBMaJaEjfqlgz2EPKoXJ4y7Ru7CUm+Tm3yJVMKF92cN9xUQR0j5KALyF0fg9aw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-azure@0.20.0': + resolution: {integrity: sha512-iRy+O2cB6DOlQ/OONaK+L8Cp8nLS89dZVRp6KgnFAfzykXuq9Ws/ygJKcU3CCmjkgY5j2Vk3uVTre/E35bWhYg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-container@0.8.4': + resolution: {integrity: sha512-kIvGHkMSacp+kb7btTuXbOAIWLyOCO+P/h/8xxaeLcp5ptmHRZ67uEdLAQo61ApdayFB/uqjJ9gY4x2/i/KsoA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-gcp@0.47.0': + resolution: {integrity: sha512-57T/kRVdU0ch1P4KPEkmU2b5mWNlUs8hHgqrBYVF+fNZMc1jMdL1mANZhEzoLtWKIeoCEy+57Itt7RkXAYNJiQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resources@2.5.1': + resolution: {integrity: sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.6.0': + resolution: {integrity: sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.212.0': + resolution: {integrity: sha512-qglb5cqTf0mOC1sDdZ7nfrPjgmAqs2OxkzOPIf2+Rqx8yKBK0pS7wRtB1xH30rqahBIut9QJDbDePyvtyqvH/Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.213.0': + resolution: {integrity: sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.5.1': + resolution: {integrity: sha512-RKMn3QKi8nE71ULUo0g/MBvq1N4icEBo7cQSKnL3URZT16/YH3nSVgWegOjwx7FRBTrjOIkMJkCUn/ZFIEfn4A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.6.0': + resolution: {integrity: sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-node@0.212.0': + resolution: {integrity: sha512-tJzVDk4Lo44MdgJLlP+gdYdMnjxSNsjC/IiTxj5CFSnsjzpHXwifgl3BpUX67Ty3KcdubNVfedeBc/TlqHXwwg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.5.1': + resolution: {integrity: sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.6.0': + resolution: {integrity: sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.5.1': + resolution: {integrity: sha512-9lopQ6ZoElETOEN0csgmtEV5/9C7BMfA7VtF4Jape3i954b6sTY2k3Xw3CxUTKreDck/vpAuJM+EDo4zheUw+A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.6.0': + resolution: {integrity: sha512-YhswtasmsbIGEFvLGvR9p/y3PVRTfFf+mgY8van4Ygpnv4sA3vooAjvh+qAn9PNWxs4/IwGGqiQS0PPsaRJ0vQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.40.0': + resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} + engines: {node: '>=14'} + + '@opentelemetry/sql-common@0.41.2': + resolution: {integrity: sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@ota-meshi/ast-token-store@0.3.0': resolution: {integrity: sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -2943,6 +3540,36 @@ packages: '@prisma/get-platform@6.19.2': resolution: {integrity: sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.4': + resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.0': + resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.0': + resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@quansync/fs@1.0.0': resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} @@ -3199,6 +3826,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/aws-lambda@8.10.161': + resolution: {integrity: sha512-rUYdp+MQwSFocxIOcSsYSF3YYYC/uUpMbCY/mbO21vGqfrEYvNSoPyKYDj6RhXXpPfS0KstW9RwG3qXh9sL7FQ==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -3214,6 +3844,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/bunyan@1.8.11': + resolution: {integrity: sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -3256,21 +3889,39 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/luxon@3.4.2': + resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/memcached@2.2.10': + resolution: {integrity: sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg==} + '@types/methods@1.1.4': resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==} '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/mysql@2.15.27': + resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==} + '@types/node@22.19.15': resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} '@types/node@24.12.0': resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} + '@types/oracledb@6.5.2': + resolution: {integrity: sha512-kK1eBS/Adeyis+3OlBDMeQQuasIDLUYXsi2T15ccNJ0iyUpQ4xDF7svFu3+bGVrI0CMBUclPciz+lsQR3JX3TQ==} + + '@types/pg-pool@2.0.7': + resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==} + + '@types/pg@8.15.6': + resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + '@types/qs@6.15.0': resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} @@ -3304,6 +3955,9 @@ packages: '@types/swagger-schema-official@2.0.25': resolution: {integrity: sha512-T92Xav+Gf/Ik1uPW581nA+JftmjWPgskw/WBf4TJzxRG/SJ+DfNnNE+WuZ4mrXuzflQMqMkm1LSYjzYW7MB1Cg==} + '@types/tedious@4.0.14': + resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} + '@types/tmp@0.2.6': resolution: {integrity: sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==} @@ -3653,6 +4307,11 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + acorn-import-phases@1.0.4: resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} engines: {node: '>=10.13.0'} @@ -3903,6 +4562,9 @@ packages: bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -4090,6 +4752,9 @@ packages: citty@0.2.1: resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==} + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -4313,6 +4978,9 @@ packages: cron-validator@1.4.0: resolution: {integrity: sha512-wGcJ9FCy65iaU6egSH8b5dZYJF7GU/3Jh06wzaT9lsa5dbqExjljmu+0cJ8cpKn+vUyZa/EM4WAxeLR6SypJXw==} + cron@3.5.0: + resolution: {integrity: sha512-0eYZqCnapmxYcV06uktql93wNWdlTmmBFP2iYz+JPVcQqlyFYcn1lFuIk4R54pkOmE7mcldTAPZv6X5XA4Q46A==} + cross-spawn@6.0.6: resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} engines: {node: '>=4.8'} @@ -4363,6 +5031,10 @@ packages: resolution: {integrity: sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==} engines: {node: '>=0.10'} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -5047,6 +5719,10 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -5139,10 +5815,17 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + formidable@3.5.4: resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==} engines: {node: '>=14.0.0'} + forwarded-parse@2.1.2: + resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -5185,6 +5868,14 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + gaxios@7.1.4: + resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -5322,6 +6013,10 @@ packages: good-listener@1.2.2: resolution: {integrity: sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==} + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -5491,6 +6186,13 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-in-the-middle@2.0.6: + resolution: {integrity: sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==} + + import-in-the-middle@3.0.0: + resolution: {integrity: sha512-OnGy+eYT7wVejH2XWgLRgbmzujhhVIATQH0ztIeRilwHBjTeG3pD+XnH3PKX0r9gJ0BuJmJ68q/oh9qgXnNDQg==} + engines: {node: '>=18'} + import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} @@ -5829,6 +6531,9 @@ packages: resolution: {integrity: sha512-Dep8wO3Fr5wNjQevO2Z8Y7yeee/nYSGRsi7q6zJDKEVHxXkXT+v21vxHmDX923UzmCXXkSo62HaTz6eTWzFLaw==} engines: {node: '>= 16'} + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -6015,6 +6720,9 @@ packages: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -6031,6 +6739,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + luxon@3.5.0: + resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} + engines: {node: '>=12'} + magic-regexp@0.10.0: resolution: {integrity: sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg==} @@ -6304,6 +7016,9 @@ packages: mnemonist@0.39.8: resolution: {integrity: sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==} + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + module-replacements@2.11.0: resolution: {integrity: sha512-j5sNQm3VCpQQ7nTqGeOZtoJtV3uKERgCBm9QRhmGRiXiqkf7iRFOkfxdJRZWLkqYY8PNf4cDQF/WfXUYLENrRA==} @@ -6390,6 +7105,11 @@ packages: node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + node-emoji@1.11.0: resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} @@ -6409,6 +7129,10 @@ packages: encoding: optional: true + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-readfiles@0.2.0: resolution: {integrity: sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA==} @@ -6662,6 +7386,17 @@ packages: performance-now@2.1.0: resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -6781,6 +7516,22 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -6816,6 +7567,14 @@ packages: resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} engines: {node: '>= 0.6.0'} + protobufjs@7.5.4: + resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + engines: {node: '>=12.0.0'} + + protobufjs@8.0.0: + resolution: {integrity: sha512-jx6+sE9h/UryaCZhsJWbJtTEy47yXoGNYI4z8ZaRncM0zBKeRqjO2JEcOUYwrYGb1WLhXM1FfMzW3annvFv0rw==} + engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -6984,6 +7743,10 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + reserved-identifiers@1.2.0: resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} engines: {node: '>=18'} @@ -8206,6 +8969,10 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -10024,6 +10791,18 @@ snapshots: vue: 3.5.30(typescript@5.9.3) vue-router: 4.6.4(vue@3.5.30(typescript@5.9.3)) + '@grpc/grpc-js@1.14.3': + dependencies: + '@grpc/proto-loader': 0.8.0 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.0': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.4 + yargs: 17.7.2 + '@himenon/argocd-typescript-openapi@1.2.2': {} '@humanfs/core@0.19.1': {} @@ -10253,200 +11032,1001 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} - '@jridgewell/trace-mapping@0.3.31': + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@js-sdsl/ordered-map@4.4.2': {} + + '@keycloak/keycloak-admin-client@26.5.5': + dependencies: + camelize-ts: 3.0.0 + url-template: 3.1.1 + + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.0 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + + '@kubernetes-models/apimachinery@2.2.0': + dependencies: + '@kubernetes-models/base': 5.0.1 + '@kubernetes-models/validate': 4.0.0 + '@swc/helpers': 0.5.19 + + '@kubernetes-models/argo-cd@2.7.2': + dependencies: + '@kubernetes-models/apimachinery': 2.2.0 + '@kubernetes-models/base': 5.0.1 + '@kubernetes-models/validate': 4.0.0 + '@swc/helpers': 0.5.19 + + '@kubernetes-models/base@5.0.1': + dependencies: + '@kubernetes-models/validate': 4.0.0 + is-plain-object: 5.0.0 + tslib: 2.8.1 + + '@kubernetes-models/validate@4.0.0': + dependencies: + ajv: 8.18.0 + ajv-formats: 2.1.1(ajv@8.18.0) + ajv-formats-draft2019: 1.6.1(ajv@8.18.0) + ajv-i18n: 4.2.0(ajv@8.18.0) + is-cidr: 4.0.2 + tslib: 2.8.1 + optionalDependencies: + re2-wasm: 1.0.2 + + '@lukeed/csprng@1.1.0': {} + + '@lukeed/ms@2.0.2': {} + + '@mswjs/interceptors@0.41.3': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + optional: true + + '@napi-rs/wasm-runtime@1.1.1': + dependencies: + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@nestjs/cli@11.0.16(@types/node@22.19.15)': + dependencies: + '@angular-devkit/core': 19.2.19(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) + '@angular-devkit/schematics-cli': 19.2.19(@types/node@22.19.15)(chokidar@4.0.3) + '@inquirer/prompts': 7.10.1(@types/node@22.19.15) + '@nestjs/schematics': 11.0.9(chokidar@4.0.3)(typescript@5.9.3) + ansis: 4.2.0 + chokidar: 4.0.3 + cli-table3: 0.6.5 + commander: 4.1.1 + fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1) + glob: 13.0.0 + node-emoji: 1.11.0 + ora: 5.4.1 + tsconfig-paths: 4.2.0 + tsconfig-paths-webpack-plugin: 4.2.0 + typescript: 5.9.3 + webpack: 5.104.1 + webpack-node-externals: 3.0.0 + transitivePeerDependencies: + - '@types/node' + - esbuild + - uglify-js + - webpack-cli + + '@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 21.3.0 + iterare: 1.2.1 + load-esm: 1.0.3 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + transitivePeerDependencies: + - supports-color + + '@nestjs/config@4.0.3(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + dotenv: 17.2.3 + dotenv-expand: 12.0.3 + lodash: 4.17.23 + rxjs: 7.8.2 + + '@nestjs/core@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nuxt/opencollective': 0.4.1 + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 8.3.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + '@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + + '@nestjs/platform-express@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cors: 2.8.6 + express: 5.2.1 + multer: 2.1.1 + path-to-regexp: 8.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + + '@nestjs/schedule@5.0.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cron: 3.5.0 + + '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)': + dependencies: + '@angular-devkit/core': 19.2.17(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.17(chokidar@4.0.3) + comment-json: 4.4.1 + jsonc-parser: 3.3.1 + pluralize: 8.0.0 + typescript: 5.9.3 + transitivePeerDependencies: + - chokidar + + '@nestjs/testing@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + tslib: 2.8.1 + optionalDependencies: + '@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + + '@noble/hashes@1.8.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@nuxt/opencollective@0.4.1': + dependencies: + consola: 3.4.2 + + '@open-draft/deferred-promise@2.2.0': + optional: true + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + optional: true + + '@open-draft/until@2.1.0': + optional: true + + '@opentelemetry/api-logs@0.212.0': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/api-logs@0.213.0': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/auto-instrumentations-node@0.70.1(@opentelemetry/api@1.9.0)(@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0))': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-amqplib': 0.59.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-aws-lambda': 0.64.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-aws-sdk': 0.67.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-bunyan': 0.57.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-cassandra-driver': 0.57.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-connect': 0.55.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-cucumber': 0.28.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-dataloader': 0.29.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-dns': 0.55.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-express': 0.60.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-fastify': 0.56.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-fs': 0.31.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-generic-pool': 0.55.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-graphql': 0.60.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-grpc': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-hapi': 0.58.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-http': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-ioredis': 0.60.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-kafkajs': 0.21.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-knex': 0.56.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-koa': 0.60.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-lru-memoizer': 0.56.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-memcached': 0.55.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mongodb': 0.65.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mongoose': 0.58.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mysql': 0.58.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-mysql2': 0.58.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-nestjs-core': 0.58.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-net': 0.56.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-openai': 0.10.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-oracledb': 0.37.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-pg': 0.64.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-pino': 0.58.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-redis': 0.60.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-restify': 0.57.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-router': 0.56.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-runtime-node': 0.25.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-socket.io': 0.59.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-tedious': 0.31.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-undici': 0.22.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-winston': 0.56.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resource-detector-alibaba-cloud': 0.33.3(@opentelemetry/api@1.9.0) + '@opentelemetry/resource-detector-aws': 2.13.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resource-detector-azure': 0.20.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resource-detector-container': 0.8.4(@opentelemetry/api@1.9.0) + '@opentelemetry/resource-detector-gcp': 0.47.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-node': 0.212.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/configuration@0.212.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + yaml: 2.8.2 + + '@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/context-async-hooks@2.6.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + + '@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/exporter-logs-otlp-grpc@0.212.0(@opentelemetry/api@1.9.0)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-logs-otlp-http@0.212.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-logs-otlp-proto@0.212.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-metrics-otlp-grpc@0.212.0(@opentelemetry/api@1.9.0)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-metrics-otlp-http@0.212.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-metrics-otlp-http@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-metrics-otlp-proto@0.212.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-metrics-otlp-proto@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-prometheus@0.212.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.212.0(@opentelemetry/api@1.9.0)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-trace-otlp-http@0.212.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-trace-otlp-proto@0.212.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-trace-otlp-proto@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/exporter-zipkin@2.5.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/instrumentation-amqplib@0.59.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-aws-lambda@0.64.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + '@types/aws-lambda': 8.10.161 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-aws-sdk@0.67.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-bunyan@0.57.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@types/bunyan': 1.8.11 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-cassandra-driver@0.57.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-connect@0.55.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + '@types/connect': 3.4.38 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-cucumber@0.28.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dataloader@0.29.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dns@0.55.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-express@0.60.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fastify@0.56.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fs@0.31.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-generic-pool@0.55.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-graphql@0.60.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-grpc@0.212.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-hapi@0.58.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-http@0.212.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + forwarded-parse: 2.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-ioredis@0.60.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/redis-common': 0.38.2 + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-kafkajs@0.21.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-knex@0.56.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-koa@0.60.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-lru-memoizer@0.56.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-memcached@0.55.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + '@types/memcached': 2.2.10 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongodb@0.65.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongoose@0.58.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql2@0.58.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql@0.58.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + '@types/mysql': 2.15.27 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-nestjs-core@0.58.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-net@0.56.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-openai@0.10.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-oracledb@0.37.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + '@types/oracledb': 6.5.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pg@0.64.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.0) + '@types/pg': 8.15.6 + '@types/pg-pool': 2.0.7 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pino@0.58.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pino@0.59.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.213.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-redis@0.60.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/redis-common': 0.38.2 + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-restify@0.57.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-router@0.56.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-runtime-node@0.25.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-socket.io@0.59.0(@opentelemetry/api@1.9.0)': dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color - '@jridgewell/trace-mapping@0.3.9': + '@opentelemetry/instrumentation-tedious@0.31.0(@opentelemetry/api@1.9.0)': dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + '@types/tedious': 4.0.14 + transitivePeerDependencies: + - supports-color - '@keycloak/keycloak-admin-client@26.5.5': + '@opentelemetry/instrumentation-undici@0.22.0(@opentelemetry/api@1.9.0)': dependencies: - camelize-ts: 3.0.0 - url-template: 3.1.1 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color - '@keyv/bigmap@1.3.1(keyv@5.6.0)': + '@opentelemetry/instrumentation-winston@0.56.0(@opentelemetry/api@1.9.0)': dependencies: - hashery: 1.5.0 - hookified: 1.15.1 - keyv: 5.6.0 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + transitivePeerDependencies: + - supports-color - '@keyv/serialize@1.1.1': {} + '@opentelemetry/instrumentation@0.212.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.212.0 + import-in-the-middle: 2.0.6 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color - '@kubernetes-models/apimachinery@2.2.0': + '@opentelemetry/instrumentation@0.213.0(@opentelemetry/api@1.9.0)': dependencies: - '@kubernetes-models/base': 5.0.1 - '@kubernetes-models/validate': 4.0.0 - '@swc/helpers': 0.5.19 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.213.0 + import-in-the-middle: 3.0.0 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color - '@kubernetes-models/argo-cd@2.7.2': + '@opentelemetry/otlp-exporter-base@0.212.0(@opentelemetry/api@1.9.0)': dependencies: - '@kubernetes-models/apimachinery': 2.2.0 - '@kubernetes-models/base': 5.0.1 - '@kubernetes-models/validate': 4.0.0 - '@swc/helpers': 0.5.19 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@kubernetes-models/base@5.0.1': + '@opentelemetry/otlp-exporter-base@0.213.0(@opentelemetry/api@1.9.0)': dependencies: - '@kubernetes-models/validate': 4.0.0 - is-plain-object: 5.0.0 - tslib: 2.8.1 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.213.0(@opentelemetry/api@1.9.0) - '@kubernetes-models/validate@4.0.0': + '@opentelemetry/otlp-grpc-exporter-base@0.212.0(@opentelemetry/api@1.9.0)': dependencies: - ajv: 8.18.0 - ajv-formats: 2.1.1(ajv@8.18.0) - ajv-formats-draft2019: 1.6.1(ajv@8.18.0) - ajv-i18n: 4.2.0(ajv@8.18.0) - is-cidr: 4.0.2 - tslib: 2.8.1 - optionalDependencies: - re2-wasm: 1.0.2 + '@grpc/grpc-js': 1.14.3 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.212.0(@opentelemetry/api@1.9.0) - '@lukeed/csprng@1.1.0': {} + '@opentelemetry/otlp-transformer@0.212.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + protobufjs: 8.0.0 - '@lukeed/ms@2.0.2': {} + '@opentelemetry/otlp-transformer@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.213.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) + protobufjs: 7.5.4 - '@mswjs/interceptors@0.41.3': + '@opentelemetry/propagator-b3@2.5.1(@opentelemetry/api@1.9.0)': dependencies: - '@open-draft/deferred-promise': 2.2.0 - '@open-draft/logger': 0.3.0 - '@open-draft/until': 2.1.0 - is-node-process: 1.2.0 - outvariant: 1.4.3 - strict-event-emitter: 0.5.1 - optional: true + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@napi-rs/wasm-runtime@1.1.1': + '@opentelemetry/propagator-jaeger@2.5.1(@opentelemetry/api@1.9.0)': dependencies: - '@emnapi/core': 1.8.1 - '@emnapi/runtime': 1.8.1 - '@tybys/wasm-util': 0.10.1 - optional: true + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) - '@nestjs/cli@11.0.16(@types/node@22.19.15)': + '@opentelemetry/redis-common@0.38.2': {} + + '@opentelemetry/resource-detector-alibaba-cloud@0.33.3(@opentelemetry/api@1.9.0)': dependencies: - '@angular-devkit/core': 19.2.19(chokidar@4.0.3) - '@angular-devkit/schematics': 19.2.19(chokidar@4.0.3) - '@angular-devkit/schematics-cli': 19.2.19(@types/node@22.19.15)(chokidar@4.0.3) - '@inquirer/prompts': 7.10.1(@types/node@22.19.15) - '@nestjs/schematics': 11.0.9(chokidar@4.0.3)(typescript@5.9.3) - ansis: 4.2.0 - chokidar: 4.0.3 - cli-table3: 0.6.5 - commander: 4.1.1 - fork-ts-checker-webpack-plugin: 9.1.0(typescript@5.9.3)(webpack@5.104.1) - glob: 13.0.0 - node-emoji: 1.11.0 - ora: 5.4.1 - tsconfig-paths: 4.2.0 - tsconfig-paths-webpack-plugin: 4.2.0 - typescript: 5.9.3 - webpack: 5.104.1 - webpack-node-externals: 3.0.0 - transitivePeerDependencies: - - '@types/node' - - esbuild - - uglify-js - - webpack-cli + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) - '@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@opentelemetry/resource-detector-aws@2.13.0(@opentelemetry/api@1.9.0)': dependencies: - file-type: 21.3.0 - iterare: 1.2.1 - load-esm: 1.0.3 - reflect-metadata: 0.2.2 - rxjs: 7.8.2 - tslib: 2.8.1 - uid: 2.0.2 - transitivePeerDependencies: - - supports-color + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 - '@nestjs/config@4.0.3(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': + '@opentelemetry/resource-detector-azure@0.20.0(@opentelemetry/api@1.9.0)': dependencies: - '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) - dotenv: 17.2.3 - dotenv-expand: 12.0.3 - lodash: 4.17.23 - rxjs: 7.8.2 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 - '@nestjs/core@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@opentelemetry/resource-detector-container@0.8.4(@opentelemetry/api@1.9.0)': dependencies: - '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nuxt/opencollective': 0.4.1 - fast-safe-stringify: 2.1.1 - iterare: 1.2.1 - path-to-regexp: 8.3.0 - reflect-metadata: 0.2.2 - rxjs: 7.8.2 - tslib: 2.8.1 - uid: 2.0.2 - optionalDependencies: - '@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) - '@nestjs/platform-express@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + '@opentelemetry/resource-detector-gcp@0.47.0(@opentelemetry/api@1.9.0)': dependencies: - '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) - cors: 2.8.6 - express: 5.2.1 - multer: 2.1.1 - path-to-regexp: 8.3.0 - tslib: 2.8.1 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + gcp-metadata: 8.1.2 transitivePeerDependencies: - supports-color - '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)': - dependencies: - '@angular-devkit/core': 19.2.17(chokidar@4.0.3) - '@angular-devkit/schematics': 19.2.17(chokidar@4.0.3) - comment-json: 4.4.1 - jsonc-parser: 3.3.1 - pluralize: 8.0.0 - typescript: 5.9.3 + '@opentelemetry/resources@2.5.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/resources@2.6.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-logs@0.212.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-logs@0.213.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.213.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-metrics@2.5.1(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-metrics@2.6.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/sdk-node@0.212.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/api-logs': 0.212.0 + '@opentelemetry/configuration': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/context-async-hooks': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-proto': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-proto': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-prometheus': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-grpc': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-proto': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-zipkin': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-b3': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-jaeger': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.212.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-node': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 transitivePeerDependencies: - - chokidar + - supports-color - '@nestjs/testing@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)': + '@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0)': dependencies: - '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) - tslib: 2.8.1 - optionalDependencies: - '@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) - - '@noble/hashes@1.8.0': {} + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 - '@nodelib/fs.scandir@2.1.5': + '@opentelemetry/sdk-trace-base@2.6.0(@opentelemetry/api@1.9.0)': dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.40.0 - '@nodelib/fs.walk@1.2.8': + '@opentelemetry/sdk-trace-node@2.5.1(@opentelemetry/api@1.9.0)': dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.5.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.5.1(@opentelemetry/api@1.9.0) - '@nuxt/opencollective@0.4.1': + '@opentelemetry/sdk-trace-node@2.6.0(@opentelemetry/api@1.9.0)': dependencies: - consola: 3.4.2 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/context-async-hooks': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.6.0(@opentelemetry/api@1.9.0) - '@open-draft/deferred-promise@2.2.0': - optional: true + '@opentelemetry/semantic-conventions@1.40.0': {} - '@open-draft/logger@0.3.0': + '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.0)': dependencies: - is-node-process: 1.2.0 - outvariant: 1.4.3 - optional: true - - '@open-draft/until@2.1.0': - optional: true + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.6.0(@opentelemetry/api@1.9.0) '@ota-meshi/ast-token-store@0.3.0': {} @@ -10566,6 +12146,29 @@ snapshots: dependencies: '@prisma/debug': 6.19.2 + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.4': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.0 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.0': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.0': {} + '@quansync/fs@1.0.0': dependencies: quansync: 1.0.0 @@ -10786,6 +12389,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aws-lambda@8.10.161': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.0 @@ -10816,6 +12421,10 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 22.19.15 + '@types/bunyan@1.8.11': + dependencies: + '@types/node': 24.12.0 + '@types/connect@3.4.38': dependencies: '@types/node': 22.19.15 @@ -10867,14 +12476,24 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/luxon@3.4.2': {} + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 + '@types/memcached@2.2.10': + dependencies: + '@types/node': 24.12.0 + '@types/methods@1.1.4': {} '@types/ms@2.1.0': {} + '@types/mysql@2.15.27': + dependencies: + '@types/node': 24.12.0 + '@types/node@22.19.15': dependencies: undici-types: 6.21.0 @@ -10883,6 +12502,20 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/oracledb@6.5.2': + dependencies: + '@types/node': 24.12.0 + + '@types/pg-pool@2.0.7': + dependencies: + '@types/pg': 8.15.6 + + '@types/pg@8.15.6': + dependencies: + '@types/node': 24.12.0 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + '@types/qs@6.15.0': {} '@types/range-parser@1.2.7': {} @@ -10921,6 +12554,10 @@ snapshots: '@types/swagger-schema-official@2.0.25': {} + '@types/tedious@4.0.14': + dependencies: + '@types/node': 24.12.0 + '@types/tmp@0.2.6': optional: true @@ -11554,6 +13191,10 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 + acorn-import-attributes@1.9.5(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn-import-phases@1.0.4(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -11796,6 +13437,8 @@ snapshots: tweetnacl: 0.14.5 optional: true + bignumber.js@9.3.1: {} + binary-extensions@2.3.0: {} bl@4.1.0: @@ -12017,6 +13660,8 @@ snapshots: citty@0.2.1: {} + cjs-module-lexer@2.2.0: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -12219,6 +13864,11 @@ snapshots: cron-validator@1.4.0: {} + cron@3.5.0: + dependencies: + '@types/luxon': 3.4.2 + luxon: 3.5.0 + cross-spawn@6.0.6: dependencies: nice-try: 1.0.5 @@ -12314,6 +13964,8 @@ snapshots: assert-plus: 1.0.0 optional: true + data-uri-to-buffer@4.0.1: {} + data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -13255,6 +14907,11 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -13372,12 +15029,18 @@ snapshots: format@0.2.2: {} + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + formidable@3.5.4: dependencies: '@paralleldrive/cuid2': 2.3.1 dezalgo: 1.0.4 once: 1.4.0 + forwarded-parse@2.1.2: {} + forwarded@0.2.0: {} fp-ts@2.16.11: {} @@ -13418,6 +15081,22 @@ snapshots: functions-have-names@1.2.3: {} + gaxios@7.1.4: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.4 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -13578,6 +15257,8 @@ snapshots: dependencies: delegate: 3.2.0 + google-logging-utils@1.1.3: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -13748,6 +15429,20 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-in-the-middle@2.0.6: + dependencies: + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + + import-in-the-middle@3.0.0: + dependencies: + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + import-meta-resolve@4.2.0: {} imurmurhash@0.1.4: {} @@ -14067,6 +15762,10 @@ snapshots: deeks: 3.1.0 doc-path: 4.1.1 + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + json-buffer@3.0.1: {} json-parse-better-errors@1.0.2: {} @@ -14284,6 +15983,8 @@ snapshots: strip-ansi: 7.2.0 wrap-ansi: 9.0.2 + long@5.3.2: {} + longest-streak@3.1.0: {} loupe@3.2.1: {} @@ -14296,6 +15997,8 @@ snapshots: dependencies: yallist: 3.1.1 + luxon@3.5.0: {} + magic-regexp@0.10.0: dependencies: estree-walker: 3.0.3 @@ -14734,6 +16437,8 @@ snapshots: dependencies: obliterator: 2.0.5 + module-details-from-path@1.0.4: {} + module-replacements@2.11.0: {} moo@0.5.3: {} @@ -14865,6 +16570,8 @@ snapshots: node-abort-controller@3.1.1: {} + node-domexception@1.0.0: {} + node-emoji@1.11.0: dependencies: lodash: 4.17.23 @@ -14879,6 +16586,12 @@ snapshots: dependencies: whatwg-url: 5.0.0 + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + node-readfiles@0.2.0: dependencies: es6-promise: 3.3.1 @@ -15181,6 +16894,18 @@ snapshots: performance-now@2.1.0: optional: true + pg-int8@1.0.1: {} + + pg-protocol@1.13.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + picocolors@1.1.1: {} picomatch-browser@2.2.6: {} @@ -15325,6 +17050,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prelude-ls@1.2.1: {} pretty-bytes@5.6.0: {} @@ -15349,6 +17084,36 @@ snapshots: process@0.11.10: optional: true + protobufjs@7.5.4: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 24.12.0 + long: 5.3.2 + + protobufjs@8.0.0: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.4 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.0 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.0 + '@types/node': 24.12.0 + long: 5.3.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -15528,6 +17293,13 @@ snapshots: require-from-string@2.0.2: {} + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + reserved-identifiers@1.2.0: {} resolve-from@4.0.0: {} @@ -17044,6 +18816,8 @@ snapshots: dependencies: defaults: 1.0.4 + web-streams-polyfill@3.3.3: {} + webidl-conversions@3.0.1: {} webidl-conversions@4.0.2: {} From 0b83064b22c6f9b32bce2333bf90e2f72a37edc7 Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 12 Mar 2026 11:57:03 +0100 Subject: [PATCH 2/4] refactor(keycloak): migrate Keycloak plugin to NestJS Signed-off-by: William Phetsinorath --- apps/server-nestjs/.env-example | 8 + apps/server-nestjs/.env.docker-example | 4 + apps/server-nestjs/package.json | 24 +- .../configuration/configuration.service.ts | 3 + .../infrastructure/database/prisma.service.ts | 14 + .../infrastructure/infrastructure.module.ts | 5 +- apps/server-nestjs/src/main.module.ts | 10 +- .../decorators/check-policies.decorator.ts | 15 + .../iam/factories/casl-ability.factory.ts | 58 + .../src/modules/iam/guards/policies.guard.ts | 37 + .../src/modules/iam/iam.module.ts | 48 + .../src/modules/keycloak/keycloack.utils.ts | 29 + .../keycloak/keycloak-client.service.ts | 32 + .../keycloak-controller.service.spec.ts | 365 +++ .../keycloak/keycloak-controller.service.ts | 636 +++++ .../keycloak/keycloak-datastore.service.ts | 50 + .../src/modules/keycloak/keycloak.constant.ts | 1 + .../src/modules/keycloak/keycloak.module.ts | 14 + .../modules/keycloak/keycloak.service.spec.ts | 76 + .../src/modules/keycloak/keycloak.service.ts | 206 ++ .../src/prisma/schema/project.prisma | 2 + apps/server-nestjs/test/app.e2e-spec.ts | 23 - apps/server-nestjs/test/keycloak.e2e-spec.ts | 531 ++++ apps/server-nestjs/tsconfig.json | 4 +- apps/server-nestjs/vitest.config.ts | 19 + pnpm-lock.yaml | 2175 ++++++++++++++++- 26 files changed, 4270 insertions(+), 119 deletions(-) create mode 100644 apps/server-nestjs/src/cpin-module/infrastructure/database/prisma.service.ts create mode 100644 apps/server-nestjs/src/modules/iam/decorators/check-policies.decorator.ts create mode 100644 apps/server-nestjs/src/modules/iam/factories/casl-ability.factory.ts create mode 100644 apps/server-nestjs/src/modules/iam/guards/policies.guard.ts create mode 100644 apps/server-nestjs/src/modules/iam/iam.module.ts create mode 100644 apps/server-nestjs/src/modules/keycloak/keycloack.utils.ts create mode 100644 apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts create mode 100644 apps/server-nestjs/src/modules/keycloak/keycloak-controller.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/keycloak/keycloak-controller.service.ts create mode 100644 apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts create mode 100644 apps/server-nestjs/src/modules/keycloak/keycloak.constant.ts create mode 100644 apps/server-nestjs/src/modules/keycloak/keycloak.module.ts create mode 100644 apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/keycloak/keycloak.service.ts delete mode 100644 apps/server-nestjs/test/app.e2e-spec.ts create mode 100644 apps/server-nestjs/test/keycloak.e2e-spec.ts create mode 100644 apps/server-nestjs/vitest.config.ts diff --git a/apps/server-nestjs/.env-example b/apps/server-nestjs/.env-example index 0525122fc..f4f7960cc 100644 --- a/apps/server-nestjs/.env-example +++ b/apps/server-nestjs/.env-example @@ -15,6 +15,14 @@ KEYCLOAK_PROTOCOL=http KEYCLOAK_CLIENT_ID=dso-console-backend # Secret du client Keycloak backend (confidentiel) KEYCLOAK_CLIENT_SECRET=client-secret-backend +# Identifiant de l'administrateur Keycloak +KEYCLOAK_ADMIN=admin +# Mot de passe de l'administrateur Keycloak +KEYCLOAK_ADMIN_PASSWORD=admin +# Identifiant administrateur Keycloak (utilisé pour l'API admin) +KEYCLOAK_ADMIN=admin +# Mot de passe administrateur Keycloak (confidentiel) +KEYCLOAK_ADMIN_PASSWORD=admin # URL de redirection après authentification Keycloak KEYCLOAK_REDIRECT_URI=http://localhost:8080 # Port d'écoute du serveur backend diff --git a/apps/server-nestjs/.env.docker-example b/apps/server-nestjs/.env.docker-example index 0dc790476..b2034118c 100644 --- a/apps/server-nestjs/.env.docker-example +++ b/apps/server-nestjs/.env.docker-example @@ -16,6 +16,10 @@ KEYCLOAK_PROTOCOL=http KEYCLOAK_CLIENT_ID=dso-console-backend # Secret du client Keycloak backend (confidentiel) KEYCLOAK_CLIENT_SECRET=client-secret-backend +# Identifiant de l'administrateur Keycloak +KEYCLOAK_ADMIN=admin +# Mot de passe de l'administrateur Keycloak +KEYCLOAK_ADMIN_PASSWORD=admin # URL de redirection après authentification Keycloak KEYCLOAK_REDIRECT_URI=http://localhost:8080 # Port d'écoute du serveur dans le réseau Docker diff --git a/apps/server-nestjs/package.json b/apps/server-nestjs/package.json index a1641059c..0a269e1d4 100644 --- a/apps/server-nestjs/package.json +++ b/apps/server-nestjs/package.json @@ -15,13 +15,22 @@ "db:migrate": "prisma migrate dev --name dso", "db:reset": "prisma migrate reset", "format": "eslint ./ --fix", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "prestart": "prisma generate", "start": "nest start", "start:debug": "nest start --debug --watch", "start:dev": "nest start --watch", - "start:prod": "node dist/main" + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "pretest": "prisma generate", + "test": "vitest run", + "test:watch": "vitest", + "pretest:cov": "prisma generate", + "test:cov": "vitest run --coverage", + "test:debug": "vitest --inspect" }, "dependencies": { + "@casl/ability": "^6.7.1", + "@casl/prisma": "^1.5.0", "@cpn-console/argocd-plugin": "workspace:^", "@cpn-console/gitlab-plugin": "workspace:^", "@cpn-console/harbor-plugin": "workspace:^", @@ -38,12 +47,13 @@ "@fastify/swagger-ui": "^4.2.0", "@gitbeaker/core": "^40.6.0", "@gitbeaker/rest": "^40.6.0", + "@keycloak/keycloak-admin-client": "^24.0.0", "@kubernetes-models/argo-cd": "^2.7.2", "@nestjs/common": "^11.1.16", "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.1.16", + "@nestjs/event-emitter": "^3.0.1", "@nestjs/platform-express": "^11.1.16", - "@prisma/client": "^6.19.2", "@nestjs/schedule": "^5.0.1", "@opentelemetry/api": "^1.9.0", "@opentelemetry/auto-instrumentations-node": "^0.70.1", @@ -54,6 +64,7 @@ "@opentelemetry/sdk-metrics": "^2.5.1", "@opentelemetry/sdk-node": "^0.212.0", "@opentelemetry/sdk-trace-node": "^2.5.1", + "@prisma/client": "^6.19.2", "@ts-rest/core": "^3.52.1", "@ts-rest/fastify": "^3.52.1", "@ts-rest/open-api": "^3.52.1", @@ -63,14 +74,17 @@ "fastify": "^4.29.1", "fastify-keycloak-adapter": "2.3.2", "json-2-csv": "^5.5.10", + "keycloak-connect": "^25.0.0", "mustache": "^4.2.0", + "nest-keycloak-connect": "^1.10.1", "nestjs-pino": "^4.6.0", "pino-http": "^11.0.0", "prisma": "^6.19.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", "undici": "^7.22.0", - "vitest-mock-extended": "^2.0.2" + "vitest-mock-extended": "^2.0.2", + "zod": "^3.25.76" }, "devDependencies": { "@cpn-console/eslint-config": "workspace:^", @@ -89,6 +103,8 @@ "eslint": "^9.39.4", "fastify-plugin": "^5.1.0", "globals": "^16.5.0", + "jest": "^30.3.0", + "msw": "^2.12.10", "nodemon": "^3.1.14", "pino-pretty": "^13.1.3", "rimraf": "^6.1.3", diff --git a/apps/server-nestjs/src/cpin-module/infrastructure/configuration/configuration.service.ts b/apps/server-nestjs/src/cpin-module/infrastructure/configuration/configuration.service.ts index b3af13bcb..f049b3764 100644 --- a/apps/server-nestjs/src/cpin-module/infrastructure/configuration/configuration.service.ts +++ b/apps/server-nestjs/src/cpin-module/infrastructure/configuration/configuration.service.ts @@ -24,7 +24,10 @@ export class ConfigurationService { keycloakRealm = process.env.KEYCLOAK_REALM keycloakClientId = process.env.KEYCLOAK_CLIENT_ID keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET + keycloakAdmin = process.env.KEYCLOAK_ADMIN + keycloakAdminPassword = process.env.KEYCLOAK_ADMIN_PASSWORD keycloakRedirectUri = process.env.KEYCLOAK_REDIRECT_URI + adminsUserId = process.env.ADMIN_KC_USER_ID ? process.env.ADMIN_KC_USER_ID.split(',') : [] diff --git a/apps/server-nestjs/src/cpin-module/infrastructure/database/prisma.service.ts b/apps/server-nestjs/src/cpin-module/infrastructure/database/prisma.service.ts new file mode 100644 index 000000000..410e662ea --- /dev/null +++ b/apps/server-nestjs/src/cpin-module/infrastructure/database/prisma.service.ts @@ -0,0 +1,14 @@ +import type { OnModuleInit, OnModuleDestroy } from '@nestjs/common' +import { Injectable } from '@nestjs/common' +import { PrismaClient } from '@prisma/client' + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + async onModuleInit() { + await this.$connect() + } + + async onModuleDestroy() { + await this.$disconnect() + } +} diff --git a/apps/server-nestjs/src/cpin-module/infrastructure/infrastructure.module.ts b/apps/server-nestjs/src/cpin-module/infrastructure/infrastructure.module.ts index 8bd7fac8a..35f17db52 100644 --- a/apps/server-nestjs/src/cpin-module/infrastructure/infrastructure.module.ts +++ b/apps/server-nestjs/src/cpin-module/infrastructure/infrastructure.module.ts @@ -2,13 +2,14 @@ import { Module } from '@nestjs/common' import { ConfigurationModule } from './configuration/configuration.module' import { DatabaseService } from './database/database.service' +import { PrismaService } from './database/prisma.service' import { HttpClientService } from './http-client/http-client.service' import { LoggerModule } from './logger/logger.module' import { ServerService } from './server/server.service' @Module({ - providers: [DatabaseService, HttpClientService, ServerService], + providers: [DatabaseService, PrismaService, HttpClientService, ServerService], imports: [LoggerModule, ConfigurationModule], - exports: [DatabaseService, HttpClientService, ServerService], + exports: [DatabaseService, PrismaService, HttpClientService, ServerService], }) export class InfrastructureModule {} diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index a8c7b3fd0..545a42b81 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -1,11 +1,19 @@ import { Module } from '@nestjs/common' +import { EventEmitterModule } from '@nestjs/event-emitter' +import { ScheduleModule } from '@nestjs/schedule' import { CpinModule } from './cpin-module/cpin.module' +import { KeycloakModule } from './modules/keycloak/keycloak.module' // This module only exists to import other module. // « One module to rule them all, and in NestJs bind them » @Module({ - imports: [CpinModule], + imports: [ + CpinModule, + KeycloakModule, + EventEmitterModule.forRoot(), + ScheduleModule.forRoot(), + ], controllers: [], providers: [], }) diff --git a/apps/server-nestjs/src/modules/iam/decorators/check-policies.decorator.ts b/apps/server-nestjs/src/modules/iam/decorators/check-policies.decorator.ts new file mode 100644 index 000000000..e08973bc8 --- /dev/null +++ b/apps/server-nestjs/src/modules/iam/decorators/check-policies.decorator.ts @@ -0,0 +1,15 @@ +import { SetMetadata } from '@nestjs/common' +import type { AppAbility } from '../factories/casl-ability.factory' + +export interface IPolicyHandler { + handle: (ability: AppAbility) => boolean +} + +type PolicyHandlerCallback = (ability: AppAbility) => boolean + +export type PolicyHandler = IPolicyHandler | PolicyHandlerCallback + +export const CHECK_POLICIES_KEY = 'check_policy' +export function CheckPolicies(...handlers: PolicyHandler[]) { + return SetMetadata(CHECK_POLICIES_KEY, handlers) +} diff --git a/apps/server-nestjs/src/modules/iam/factories/casl-ability.factory.ts b/apps/server-nestjs/src/modules/iam/factories/casl-ability.factory.ts new file mode 100644 index 000000000..f5a1f3833 --- /dev/null +++ b/apps/server-nestjs/src/modules/iam/factories/casl-ability.factory.ts @@ -0,0 +1,58 @@ +import type { PureAbility } from '@casl/ability' +import { AbilityBuilder } from '@casl/ability' +import type { PrismaQuery, Subjects } from '@casl/prisma' +import { createPrismaAbility } from '@casl/prisma' +import { Injectable } from '@nestjs/common' +import type { Project, Environment, User, ProjectMembers } from '@prisma/client' + +export type AppAbility = PureAbility< + [string, Subjects<{ Project: Project, Environment: Environment, User: User, ProjectMembers: ProjectMembers }>], + PrismaQuery +> + +@Injectable() +export class CaslAbilityFactory { + createForUser(user: any) { + const { can, build } = new AbilityBuilder( + createPrismaAbility, + ) + + // If user is not authenticated or doesn't have an ID + if (!user?.sub) { + return build() + } + + const userId = user.sub + + // A user can read projects they are a member of (via ProjectMembers) + can('read', 'Project', { + members: { + some: { + userId, + }, + }, + }) + + // A project owner can manage everything + can('manage', 'Project', { + ownerId: userId, + }) + + // A user can update an environment if the project is not locked + // and they are a member of the project + can('update', 'Environment', { + project: { + is: { + locked: false, + members: { + some: { + userId, + }, + }, + }, + }, + }) + + return build() + } +} diff --git a/apps/server-nestjs/src/modules/iam/guards/policies.guard.ts b/apps/server-nestjs/src/modules/iam/guards/policies.guard.ts new file mode 100644 index 000000000..1c8e5f14d --- /dev/null +++ b/apps/server-nestjs/src/modules/iam/guards/policies.guard.ts @@ -0,0 +1,37 @@ +import type { CanActivate, ExecutionContext } from '@nestjs/common' +import { Inject, Injectable } from '@nestjs/common' +import { Reflector } from '@nestjs/core' +import { CaslAbilityFactory } from '../factories/casl-ability.factory' +import type { AppAbility } from '../factories/casl-ability.factory' +import type { PolicyHandler } from '../decorators/check-policies.decorator' +import { CHECK_POLICIES_KEY } from '../decorators/check-policies.decorator' + +@Injectable() +export class PoliciesGuard implements CanActivate { + constructor( + @Inject(Reflector) private readonly reflector: Reflector, + @Inject(CaslAbilityFactory) private readonly caslAbilityFactory: CaslAbilityFactory, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const policyHandlers + = this.reflector.get( + CHECK_POLICIES_KEY, + context.getHandler(), + ) || [] + + const { user } = context.switchToHttp().getRequest() + const ability = this.caslAbilityFactory.createForUser(user) + + return policyHandlers.every(handler => + this.execPolicyHandler(handler, ability), + ) + } + + private execPolicyHandler(handler: PolicyHandler, ability: AppAbility) { + if (typeof handler === 'function') { + return handler(ability) + } + return handler.handle(ability) + } +} diff --git a/apps/server-nestjs/src/modules/iam/iam.module.ts b/apps/server-nestjs/src/modules/iam/iam.module.ts new file mode 100644 index 000000000..a71ebd2b6 --- /dev/null +++ b/apps/server-nestjs/src/modules/iam/iam.module.ts @@ -0,0 +1,48 @@ +import { Module } from '@nestjs/common' +import { APP_GUARD } from '@nestjs/core' +import { + AuthGuard, + ResourceGuard, + KeycloakConnectModule, + PolicyEnforcementMode, + TokenValidation, +} from 'nest-keycloak-connect' +import { ConfigurationModule } from '../../cpin-module/infrastructure/configuration/configuration.module' +import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service' +import { PoliciesGuard } from './guards/policies.guard' +import { CaslAbilityFactory } from './factories/casl-ability.factory' + +@Module({ + imports: [ + ConfigurationModule, + KeycloakConnectModule.registerAsync({ + imports: [ConfigurationModule], + useFactory: (config: ConfigurationService) => ({ + authServerUrl: `${config.keycloakProtocol}://${config.keycloakDomain}`, + realm: config.keycloakRealm!, + clientId: config.keycloakClientId!, + secret: config.keycloakClientSecret!, + policyEnforcement: PolicyEnforcementMode.PERMISSIVE, + tokenValidation: TokenValidation.ONLINE, + }), + inject: [ConfigurationService], + }), + ], + providers: [ + CaslAbilityFactory, + { + provide: APP_GUARD, + useClass: AuthGuard, + }, + { + provide: APP_GUARD, + useClass: ResourceGuard, + }, + { + provide: APP_GUARD, + useClass: PoliciesGuard, + }, + ], + exports: [CaslAbilityFactory], +}) +export class IamModule {} diff --git a/apps/server-nestjs/src/modules/keycloak/keycloack.utils.ts b/apps/server-nestjs/src/modules/keycloak/keycloack.utils.ts new file mode 100644 index 000000000..d126a92dc --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloack.utils.ts @@ -0,0 +1,29 @@ +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' +import type { ProjectWithDetails } from './keycloak-datastore.service' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' + +export type GroupRepresentationWith = GroupRepresentation & Required> + +export function isMember(project: ProjectWithDetails, member: UserRepresentation) { + return project.members.some(m => m.user.id === member.id) || project.ownerId === member.id +} + +export async function* map( + iterable: AsyncIterable, + mapper: (value: T, index: number) => U | Promise, +): AsyncIterable { + let index = 0 + for await (const value of iterable) { + yield await mapper(value, index++) + } +} + +export async function getAll( + iterable: AsyncIterable, +): Promise { + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + } + return items +} diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts new file mode 100644 index 000000000..a7a0bf01d --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts @@ -0,0 +1,32 @@ +import { Inject, Injectable, Logger } from '@nestjs/common' +import type { OnModuleInit } from '@nestjs/common' +import KcAdminClient from '@keycloak/keycloak-admin-client' +import { ConfigurationService } from '../../cpin-module/infrastructure/configuration/configuration.service' + +@Injectable() +export class KeycloakClientService extends KcAdminClient implements OnModuleInit { + private readonly logger = new Logger(KeycloakClientService.name) + + constructor( + @Inject(ConfigurationService) private readonly config: ConfigurationService, + ) { + super({ + baseUrl: `${config.keycloakProtocol}://${config.keycloakDomain}`, + realmName: config.keycloakRealm, + }) + } + + async onModuleInit() { + if (!this.config.keycloakAdmin || !this.config.keycloakAdminPassword) { + this.logger.fatal('Keycloak admin or admin password not configured') + return + } + await this.auth({ + clientId: 'admin-cli', + grantType: 'password', + username: this.config.keycloakAdmin, + password: this.config.keycloakAdminPassword, + }) + this.logger.log('Keycloak Admin Client authenticated') + } +} diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-controller.service.spec.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-controller.service.spec.ts new file mode 100644 index 000000000..c4d66a66d --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-controller.service.spec.ts @@ -0,0 +1,365 @@ +import { Test } from '@nestjs/testing' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { Mocked } from 'vitest' +import { KeycloakControllerService } from './keycloak-controller.service' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import type { ProjectWithDetails } from './keycloak-datastore.service' +import { KeycloakService } from './keycloak.service' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' + +function createKeycloakControllerServiceTestingModule() { + return Test.createTestingModule({ + providers: [ + KeycloakControllerService, + { + provide: KeycloakService, + useValue: { + getAllGroups: vi.fn().mockImplementation(async function* () {}), + deleteGroup: vi.fn().mockResolvedValue(undefined), + getOrCreateGroupByPath: vi.fn().mockResolvedValue({}), + getGroupMembers: vi.fn().mockResolvedValue([]), + addUserToGroup: vi.fn().mockResolvedValue(undefined), + removeUserFromGroup: vi.fn().mockResolvedValue(undefined), + getOrCreateSubGroupByName: vi.fn().mockResolvedValue({}), + getOrCreateRoleGroup: vi.fn().mockResolvedValue({}), + getSubGroups: vi.fn().mockImplementation(async function* () {}), + getOrCreateConsoleGroup: vi.fn().mockResolvedValue({ id: 'console-group-id', name: 'console' }), + getOrCreateEnvironmentGroups: vi.fn().mockResolvedValue({ + roGroup: { id: 'ro-id', name: 'RO' }, + rwGroup: { id: 'rw-id', name: 'RW' }, + }), + } satisfies Partial, + }, + { + provide: KeycloakDatastoreService, + useValue: { + getAllProjects: vi.fn().mockResolvedValue([]), + } satisfies Partial, + }, + { + provide: ConfigurationService, + useValue: { + } satisfies Partial, + }, + ], + }) +} + +describe('keycloakControllerService', () => { + let service: KeycloakControllerService + let keycloak: Mocked + let keycloakDatastore: Mocked + + beforeEach(async () => { + vi.clearAllMocks() + const module = await createKeycloakControllerServiceTestingModule().compile() + service = module.get(KeycloakControllerService) + keycloak = module.get(KeycloakService) + keycloakDatastore = module.get(KeycloakDatastoreService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('reconcile', () => { + const mockProject: ProjectWithDetails = { + id: 'project-id', + slug: 'test-project', + ownerId: 'owner-id', + everyonePerms: 0n, + members: [], + roles: [], + environments: [], + } + + it('should purge orphans', async () => { + // Setup + keycloakDatastore.getAllProjects.mockResolvedValue([mockProject]) + + const projectGroup = { id: 'group-id', name: 'test-project', subGroups: [] } + const orphanGroup = { id: 'orphan-id', name: 'orphan-project', subGroups: [{ name: 'console' }] } + + keycloak.getAllGroups.mockImplementation(async function* () { + yield projectGroup + yield orphanGroup + }) + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getGroupMembers.mockResolvedValue([]) + keycloak.getOrCreateSubGroupByName.mockResolvedValue({ id: 'console-id', name: 'console' }) + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + await service.handleCron() + + expect(keycloakDatastore.getAllProjects).toHaveBeenCalled() + expect(keycloak.getAllGroups).toHaveBeenCalled() + expect(keycloak.getOrCreateGroupByPath).toHaveBeenCalledWith('/test-project') + expect(keycloak.deleteGroup).toHaveBeenCalledWith('orphan-id') + }) + + it('should sync project members', async () => { + // Setup + const projectWithMembers = { + ...mockProject, + members: [{ user: { id: 'user-1', email: 'user1@example.com' }, roleIds: [] }], + } + keycloakDatastore.getAllProjects.mockResolvedValue([projectWithMembers]) + + const projectGroup = { id: 'group-id', name: 'test-project' } + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + + // Current members: user-2 (extra), missing user-1 + keycloak.getGroupMembers.mockResolvedValue([ + { id: 'user-2', email: 'user2@example.com' }, + ]) + + keycloak.getOrCreateSubGroupByName.mockResolvedValue({ id: 'console-id', name: 'console' }) + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Should add missing member + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'group-id') + // Should add owner (missing in group members) + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('owner-id', 'group-id') + // Should remove extra member + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'group-id') + }) + + it('should sync OIDC role groups', async () => { + // Setup + const roleWithOidc = { + id: 'role-oidc', + permissions: 0n, + oidcGroup: '/oidc-group', + type: 'managed', + } + const projectWithRole = { + ...mockProject, + members: [{ user: { id: 'user-1', email: 'user1@example.com' }, roleIds: ['role-oidc'] }], + roles: [roleWithOidc], + } + keycloakDatastore.getAllProjects.mockResolvedValue([projectWithRole]) + + const projectGroup = { id: 'group-id', name: 'test-project' } + const consoleGroup = { id: 'console-id', name: 'console' } + const roleGroup = { id: 'role-group-id', name: 'oidc-group', path: '/console/oidc-group' } + + keycloak.getOrCreateGroupByPath.mockImplementation((path) => { + if (path === '/test-project') return Promise.resolve(projectGroup) + return Promise.resolve({}) + }) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(consoleGroup) + keycloak.getOrCreateRoleGroup.mockResolvedValue(roleGroup) + + // Project members: owner + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([{ id: 'owner-id' }]) + // Role group members: user-2 (extra), missing user-1 + if (groupId === 'role-group-id') return Promise.resolve([{ id: 'user-2', email: 'user2@example.com' }]) + return Promise.resolve([]) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Should create/get role group + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/oidc-group') + // Should add user-1 to role group + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'role-group-id') + // Should remove user-2 from role group + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'role-group-id') + }) + + it('should sync environment groups', async () => { + // Setup + const projectWithEnv = { + ...mockProject, + environments: [{ id: 'env-1', name: 'dev' }], + } + keycloakDatastore.getAllProjects.mockResolvedValue([projectWithEnv]) + + const projectGroup = { id: 'group-id', name: 'test-project', subGroups: [{ name: 'console', id: 'console-id' }] } + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getGroupMembers.mockResolvedValue([]) + + // Mock console group retrieval + keycloak.getOrCreateConsoleGroup.mockResolvedValue({ id: 'console-id', name: 'console' }) + keycloak.getOrCreateEnvironmentGroups.mockResolvedValue({ + roGroup: { id: 'dev-ro-id', name: 'RO' }, + rwGroup: { id: 'dev-rw-id', name: 'RW' }, + }) + keycloak.getOrCreateSubGroupByName.mockImplementation((_parentId, name) => { + if (name === 'console') return Promise.resolve({ id: 'console-id', name: 'console' }) + if (name === 'dev') return Promise.resolve({ id: 'dev-id', name: 'dev' }) + if (name === 'RO') return Promise.resolve({ id: 'dev-ro-id', name: 'RO' }) + if (name === 'RW') return Promise.resolve({ id: 'dev-rw-id', name: 'RW' }) + return Promise.resolve({ id: 'new-id', name }) + }) + + // Mock existing environments: 'staging' (extra) + keycloak.getSubGroups.mockImplementation(async function* (parentId) { + if (parentId === 'console-id') { + yield { id: 'staging-id', name: 'staging' } + } + if (parentId === 'staging-id') { + yield { name: 'RO' } + yield { name: 'RW' } + } + }) + + await service.handleCron() + + // Should create dev group + expect(keycloak.getOrCreateConsoleGroup).toHaveBeenCalledWith({ id: 'group-id', name: 'test-project' }) + // Should create RO/RW groups + expect(keycloak.getOrCreateEnvironmentGroups).toHaveBeenCalledWith({ id: 'console-id', name: 'console' }, projectWithEnv.environments[0]) + // Should delete staging group + expect(keycloak.deleteGroup).toHaveBeenCalledWith('staging-id') + }) + + it('should sync environment permissions', async () => { + // Setup + + const userRo = { id: 'user-ro', email: 'ro@example.com' } + const userRw = { id: 'user-rw', email: 'rw@example.com' } + const userNone = { id: 'user-none', email: 'none@example.com' } + + const projectWithEnvAndMembers = { + id: mockProject.id, + slug: mockProject.slug, + ownerId: mockProject.ownerId, + everyonePerms: mockProject.everyonePerms, + plugins: [], + members: [ + { userId: userRo.id, user: userRo, roleIds: ['role-ro'] }, + { userId: userRw.id, user: userRw, roleIds: ['role-rw'] }, + { userId: userNone.id, user: userNone, roleIds: [] }, + ], + roles: [ + { id: 'role-ro', permissions: BigInt(256), oidcGroup: '', type: 'managed' }, // ListEnvironments (bit 8) + { id: 'role-rw', permissions: BigInt(8), oidcGroup: '', type: 'managed' }, // ManageEnvironments (bit 3) + ], + environments: [{ id: 'env-1', name: 'dev' }], + } + keycloakDatastore.getAllProjects.mockResolvedValue([projectWithEnvAndMembers]) + + const projectGroup = { id: 'group-id', name: 'test-project', subGroups: [{ name: 'console', id: 'console-id' }] } + keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) + keycloak.getOrCreateConsoleGroup.mockResolvedValue({ id: 'console-id', name: 'console' }) + keycloak.getOrCreateEnvironmentGroups.mockResolvedValue({ + roGroup: { id: 'dev-ro-id', name: 'RO' }, + rwGroup: { id: 'dev-rw-id', name: 'RW' }, + }) + + // Project group members (assume all are in project group for simplicity) + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([userRo, userRw, userNone]) + // RO group has userNone (extra), missing userRo + if (groupId === 'dev-ro-id') return Promise.resolve([userNone]) + // RW group has userNone (extra), missing userRw + if (groupId === 'dev-rw-id') return Promise.resolve([userNone]) + return Promise.resolve([]) + }) + + keycloak.getOrCreateSubGroupByName.mockImplementation((_parentId, name) => { + if (name === 'console') return Promise.resolve({ id: 'console-id', name: 'console' }) + if (name === 'dev') return Promise.resolve({ id: 'dev-id', name: 'dev' }) + if (name === 'RO') return Promise.resolve({ id: 'dev-ro-id', name: 'RO' }) + if (name === 'RW') return Promise.resolve({ id: 'dev-rw-id', name: 'RW' }) + return Promise.resolve({ id: 'new-id', name }) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Sync RO + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-ro', 'dev-ro-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-none', 'dev-ro-id') + // Sync RW + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-rw', 'dev-rw-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-none', 'dev-rw-id') + }) + + it('should handle different role types (managed, external, global)', async () => { + // Setup + const roleManaged = { + id: 'role-managed', + permissions: 0n, + oidcGroup: '/managed-group', + type: 'managed', + } + const roleExternal = { + id: 'role-external', + permissions: 0n, + oidcGroup: '/external-group', + type: 'external', + } + const roleGlobal = { + id: 'role-global', + permissions: 0n, + oidcGroup: '/global-group', + type: 'global', + } + + const projectWithRoles = { + ...mockProject, + members: [{ user: { id: 'user-1', email: 'user1@example.com' }, roleIds: ['role-managed', 'role-external', 'role-global'] }], + roles: [roleManaged, roleExternal, roleGlobal], + } + keycloakDatastore.getAllProjects.mockResolvedValue([projectWithRoles]) + + const projectGroup = { id: 'group-id', name: 'test-project' } + const consoleGroup = { id: 'console-id', name: 'console' } + const managedGroup = { id: 'managed-id', name: 'managed-group' } + const externalGroup = { id: 'external-id', name: 'external-group' } + const globalGroup = { id: 'global-id', name: 'global-group' } + + keycloak.getOrCreateGroupByPath.mockImplementation((path) => { + if (path === '/test-project') return Promise.resolve(projectGroup) + return Promise.resolve({}) + }) + keycloak.getOrCreateConsoleGroup.mockResolvedValue(consoleGroup) + keycloak.getOrCreateRoleGroup.mockImplementation((_consoleGroup, oidcGroup) => { + if (oidcGroup === '/managed-group') return Promise.resolve({ ...managedGroup, path: '/console/managed-group' }) + if (oidcGroup === '/external-group') return Promise.resolve({ ...externalGroup, path: '/console/external-group' }) + if (oidcGroup === '/global-group') return Promise.resolve({ ...globalGroup, path: '/console/global-group' }) + return Promise.resolve({ id: 'new-id', name: oidcGroup, path: `/console/${oidcGroup}` }) + }) + + // Group members + keycloak.getGroupMembers.mockImplementation((groupId) => { + if (groupId === 'group-id') return Promise.resolve([{ id: 'owner-id' }]) + + // Managed: has extra user-2, missing user-1 + if (groupId === 'managed-id') return Promise.resolve([{ id: 'user-2' }]) + + // External: has extra user-2, missing user-1 + if (groupId === 'external-id') return Promise.resolve([{ id: 'user-2' }]) + + // Global: create group if it doesn't exist but no members + if (groupId === 'global-id') return Promise.resolve([{ id: 'user-2' }]) + + return Promise.resolve([]) + }) + + keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) + + await service.handleCron() + + // Managed: should add user-1, remove user-2 + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/managed-group') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'managed-id') + expect(keycloak.removeUserFromGroup).toHaveBeenCalledWith('user-2', 'managed-id') + + // External: should add user-1, NOT remove user-2 + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/external-group') + expect(keycloak.addUserToGroup).toHaveBeenCalledWith('user-1', 'external-id') + expect(keycloak.removeUserFromGroup).not.toHaveBeenCalledWith('user-2', 'external-id') + + // Global: should sync group but no members + expect(keycloak.getOrCreateRoleGroup).toHaveBeenCalledWith(consoleGroup, '/global-group') + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-controller.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-controller.service.ts new file mode 100644 index 000000000..8facc3fef --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-controller.service.ts @@ -0,0 +1,636 @@ +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { Cron, CronExpression } from '@nestjs/schedule' +import { ProjectAuthorized, getPermsByUserRoles, resourceListToDict } from '@cpn-console/shared' +import { KeycloakService } from './keycloak.service' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' +import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation.js' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import type { ProjectWithDetails } from './keycloak-datastore.service' +import { CONSOLE_GROUP_NAME } from './keycloak.constant' +import type { GroupRepresentationWith } from './keycloack.utils' +import { getAll, isMember, map } from './keycloack.utils' +import { trace } from '@opentelemetry/api' +import z from 'zod' + +const tracer = trace.getTracer('keycloak-controller') + +@Injectable() +export class KeycloakControllerService { + private readonly logger = new Logger(KeycloakControllerService.name) + + constructor( + @Inject(KeycloakService) private readonly keycloak: KeycloakService, + @Inject(KeycloakDatastoreService) private readonly keycloakDatastore: KeycloakDatastoreService, + ) { + this.logger.log('KeycloakControllerService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails) { + return tracer.startActiveSpan('handleUpsert', async (span) => { + try { + span.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project upsert for ${project.slug}`) + await this.ensureProjectGroups([project]) + span.end() + } catch (error) { + if (error instanceof Error) span.recordException(error) + span.end() + throw error + } + }) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails) { + return tracer.startActiveSpan('handleDelete', async (span) => { + try { + span.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project delete for ${project.slug}`) + await this.purgeOrphanGroups([project]) + span.end() + } catch (error) { + if (error instanceof Error) span.recordException(error) + span.end() + throw error + } + }) + } + + @Cron(CronExpression.EVERY_HOUR) + async handleCron() { + return tracer.startActiveSpan('handleCron', async (span) => { + try { + this.logger.log('Starting periodic Keycloak reconciliation') + const projects = await this.keycloakDatastore.getAllProjects() + span.setAttribute('projects.count', projects.length) + this.logger.debug(`Reconciling ${projects.length} projects`) + await this.ensureProjectGroups(projects) + await this.purgeOrphanGroups(projects) + span.end() + this.logger.log('Periodic Keycloak reconciliation completed') + } catch (error) { + if (error instanceof Error) span.recordException(error) + span.end() + throw error + } + }) + } + + private async ensureProjectGroups(projects: ProjectWithDetails[]) { + return tracer.startActiveSpan('ensureProjectGroups', async (span) => { + try { + span.setAttribute('projects.count', projects.length) + await Promise.all(projects.map(project => this.ensureProjectGroup(project))) + span.end() + } catch (error) { + if (error instanceof Error) span.recordException(error) + span.end() + throw error + } + }) + } + + private async ensureProjectGroup(project: ProjectWithDetails) { + return tracer.startActiveSpan('ensureProjectGroup', async (span) => { + try { + span.setAttribute('project.slug', project.slug) + span.setAttribute('project.members.count', project.members.length) + span.setAttribute('project.roles.count', project.roles.length) + span.setAttribute('project.environments.count', project.environments.length) + + const projectGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.keycloak.getOrCreateGroupByPath(`/${project.slug}`)) + + span.setAttribute('keycloak.project_group.id', projectGroup.id) + + await Promise.all([ + this.ensureProjectGroupMembers(project, projectGroup), + this.ensureConsoleGroup(project, projectGroup), + ]) + + span.end() + } catch (error) { + if (error instanceof Error) span.recordException(error) + span.end() + throw error + } + }) + } + + private async ensureConsoleGroup(project: ProjectWithDetails, group: GroupRepresentationWith<'id'>) { + const consoleGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.keycloak.getOrCreateConsoleGroup(group)) + await Promise.all([ + this.ensureRoleGroups(project, consoleGroup), + this.ensureEnvironmentGroups(project, consoleGroup), + this.purgeOrphanEnvironmentGroups(project, consoleGroup), + ]) + } + + private async purgeOrphanGroups(projects: ProjectWithDetails[]) { + return tracer.startActiveSpan('purgeOrphanGroups', async (span) => { + try { + const groups = this.keycloak.getAllGroups() + const projectSlugs = new Set(projects.map(p => p.slug)) + const promises: Promise[] = [] + let purgedCount = 0 + + for await (const group of groups) { + if (group.name && !projectSlugs.has(group.name)) { + if (this.isOwnedProjectGroup(group)) { + if (group.id) { + this.logger.log(`Deleting orphan Keycloak group: ${group.name}`) + purgedCount++ + promises.push( + this.keycloak.deleteGroup(group.id) + .catch((error) => { + this.logger.error(`Failed to delete orphan group ${group.name}`, error) + if (error instanceof Error) span.recordException(error) + }), + ) + } else { + this.logger.warn(`Orphan Keycloak group detected but ID is missing: ${group.name}`) + } + } + } + } + span.setAttribute('purged.count', purgedCount) + await Promise.all(promises) + span.end() + } catch (error) { + if (error instanceof Error) span.recordException(error) + span.end() + throw error + } + }) + } + + private isOwnedProjectGroup(group: GroupRepresentation) { + // Safety check: Only delete if it looks like a project group (has 'console' subgroup) + // or if we can be sure it's not a system group. + // For now, we rely on the 'console' subgroup heuristic as it's created by us. + return !!group.subGroups?.some(sg => sg.name === CONSOLE_GROUP_NAME) + } + + private async maybeAddUserToGroup(userId: string, groupId: string, groupName: string) { + try { + await this.keycloak.addUserToGroup(userId, groupId) + this.logger.log(`Added ${userId} to keycloak group ${groupName}`) + } catch (e) { + if (e.response?.status === 404) { + this.logger.warn(`User ${userId} not found in Keycloak, skipping addition to group ${groupName}`) + } else if (e.response?.status === 409) { + this.logger.debug(`User ${userId} is already a member of keycloak group ${groupName}`) + } else { + throw e + } + } + } + + private async maybeRemoveUserFromGroup(userId: string, groupId: string, groupName: string) { + try { + await this.keycloak.removeUserFromGroup(userId, groupId) + this.logger.log(`Removed ${userId} from keycloak group ${groupName}`) + } catch (e) { + if (e.response?.status === 404) { + this.logger.warn(`User ${userId} not found in Keycloak, skipping removal from group ${groupName}`) + } else { + throw e + } + } + } + + private async ensureProjectGroupMembers( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id' | 'name'>, + ) { + return tracer.startActiveSpan('ensureProjectGroupMembers', async (span) => { + try { + span.setAttribute('project.slug', project.slug) + span.setAttribute('keycloak.group.id', group.id) + + const groupMembers = await this.keycloak.getGroupMembers(group.id) + const desiredUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) + + span.setAttribute('keycloak.group.members.current', groupMembers.length) + span.setAttribute('keycloak.group.members.desired', desiredUserIds.size) + + let addedCount = 0 + let removedCount = 0 + + await Promise.all([ + ...Array.from(desiredUserIds, async (userId) => { + if (!groupMembers.some(m => m.id === userId)) { + addedCount++ + await this.maybeAddUserToGroup(userId, group.id, group.name) + } + }), + ...groupMembers.map(async (member) => { + if (member.id && !desiredUserIds.has(member.id)) { + removedCount++ + await this.maybeRemoveUserFromGroup(member.id, group.id, group.name) + } + }), + ]) + + span.setAttribute('keycloak.group.members.added', addedCount) + span.setAttribute('keycloak.group.members.removed', removedCount) + + span.end() + } catch (error) { + if (error instanceof Error) span.recordException(error) + span.end() + throw error + } + }) + } + + private async ensureRoleGroups( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id' | 'name'>, + ) { + return tracer.startActiveSpan('ensureRoleGroups', async (span) => { + try { + span.setAttribute('project.slug', project.slug) + span.setAttribute('keycloak.group.id', group.id) + span.setAttribute('project.roles.count', project.roles.length) + + const rolesWithOidcGroup = project.roles.filter(r => !!r.oidcGroup).length + span.setAttribute('project.roles.oidc_group.count', rolesWithOidcGroup) + + await Promise.all(project.roles.map(async (role) => { + if (!role.oidcGroup) return + + return tracer.startActiveSpan('ensureRoleGroup', async (roleSpan) => { + try { + roleSpan.setAttribute('project.slug', project.slug) + roleSpan.setAttribute('role.id', role.id) + roleSpan.setAttribute('role.type', role.type) + roleSpan.setAttribute('role.oidc_group', role.oidcGroup) + + const roleGroup = await this.keycloak.getOrCreateRoleGroup(group, role.oidcGroup) + roleSpan.setAttribute('keycloak.group.id', roleGroup.id) + roleSpan.setAttribute('keycloak.group.path', roleGroup.path) + + const groupMembers = await this.keycloak.getGroupMembers(roleGroup.id) + roleSpan.setAttribute('keycloak.group.members.current', groupMembers.length) + + switch (role.type) { + case 'managed': + await Promise.all([ + this.ensureRoleGroupMembers(project, role, roleGroup, groupMembers), + this.purgeOrphanRoleGroupMembers(project, role, roleGroup, groupMembers), + ]) + break + case 'external': + await this.ensureRoleGroupMembers(project, role, roleGroup, groupMembers) + break + case 'global': + await this.ensureRoleGroupMembers(project, role, roleGroup, groupMembers) + break + } + + roleSpan.end() + } catch (error) { + if (error instanceof Error) roleSpan.recordException(error) + roleSpan.end() + throw error + } + }) + })) + + span.end() + } catch (error) { + if (error instanceof Error) span.recordException(error) + span.end() + throw error + } + }) + } + + private async ensureRoleGroupMembers( + project: ProjectWithDetails, + role: ProjectWithDetails['roles'][number], + group: GroupRepresentationWith<'id' | 'name' | 'path'>, + members: UserRepresentation[], + ) { + return tracer.startActiveSpan('ensureRoleGroupMembers', async (span) => { + try { + span.setAttribute('project.slug', project.slug) + span.setAttribute('role.id', role.id) + span.setAttribute('role.type', role.type) + span.setAttribute('keycloak.group.id', group.id) + span.setAttribute('keycloak.group.members.current', members.length) + + const desiredMemberIds = project.members + .filter(m => m.roleIds.includes(role.id)) + .map(m => m.user.id) + + span.setAttribute('keycloak.group.members.desired', desiredMemberIds.length) + + let addedCount = 0 + await Promise.all(project.members.map(async (member) => { + if (!members.some(m => m.id === member.user.id) && member.roleIds.includes(role.id)) { + addedCount++ + await this.maybeAddUserToGroup(member.user.id, group.id, group.name) + } + })) + + span.setAttribute('keycloak.group.members.added', addedCount) + span.end() + } catch (error) { + if (error instanceof Error) span.recordException(error) + span.end() + throw error + } + }) + } + + private async purgeOrphanRoleGroupMembers( + project: ProjectWithDetails, + role: ProjectWithDetails['roles'][number], + group: GroupRepresentationWith<'id' | 'name' | 'path'>, + members: UserRepresentation[], + ) { + return tracer.startActiveSpan('purgeOrphanRoleGroupMembers', async (span) => { + try { + span.setAttribute('project.slug', project.slug) + span.setAttribute('role.id', role.id) + span.setAttribute('role.type', role.type) + span.setAttribute('keycloak.group.id', group.id) + span.setAttribute('keycloak.group.members.current', members.length) + + let removedCount = 0 + await Promise.all(members.map(async (member) => { + if (!isMember(project, member)) { + if (!member.id) { + throw new Error(`Failed to create or retrieve role group for ${role.oidcGroup}`) + } + removedCount++ + await this.maybeRemoveUserFromGroup(member.id, group.id, group.name) + } + })) + span.setAttribute('keycloak.group.members.removed', removedCount) + span.end() + } catch (error) { + if (error instanceof Error) span.recordException(error) + span.end() + throw error + } + }) + } + + private async ensureEnvironmentGroups( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id'>, + ) { + await Promise.all(project.environments.map(environment => + this.ensureEnvironmentGroup(project, environment, group))) + } + + private async ensureEnvironmentGroup( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + group: GroupRepresentationWith<'id'>, + ) { + return tracer.startActiveSpan('ensureEnvironmentGroup', async (span) => { + try { + span.setAttribute('project.slug', project.slug) + span.setAttribute('environment.id', environment.id) + span.setAttribute('environment.name', environment.name) + span.setAttribute('project.roles.count', project.roles.length) + + const { roGroup, rwGroup } = z.object({ + roGroup: z.object({ + id: z.string(), + name: z.string(), + }), + rwGroup: z.object({ + id: z.string(), + name: z.string(), + }), + }).parse(await this.keycloak.getOrCreateEnvironmentGroups(group, environment)) + + span.setAttribute('keycloak.env_group.ro.id', roGroup.id) + span.setAttribute('keycloak.env_group.rw.id', rwGroup.id) + + const rolesById = resourceListToDict(project.roles) + + const [roMembers, rwMembers] = await Promise.all([ + this.keycloak.getGroupMembers(roGroup.id), + this.keycloak.getGroupMembers(rwGroup.id), + ]) + + span.setAttribute('keycloak.env_group.ro.members.current', roMembers.length) + span.setAttribute('keycloak.env_group.rw.members.current', rwMembers.length) + + await Promise.all([ + this.ensureEnvironmentGroupMembers( + project, + environment, + rolesById, + roGroup, + rwGroup, + roMembers, + rwMembers, + ), + this.purgeOrphanEnvironmentGroupMembers( + project, + environment, + roGroup, + rwGroup, + roMembers, + rwMembers, + ), + ]) + + span.end() + } catch (error) { + if (error instanceof Error) span.recordException(error) + span.end() + throw error + } + }) + } + + private async ensureEnvironmentGroupMembers( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + rolesById: Record, + roGroup: GroupRepresentationWith<'id'>, + rwGroup: GroupRepresentationWith<'id'>, + roMembers: UserRepresentation[], + rwMembers: UserRepresentation[], + ) { + return tracer.startActiveSpan('ensureEnvironmentGroupMembers', async (span) => { + try { + span.setAttribute('project.slug', project.slug) + span.setAttribute('environment.id', environment.id) + span.setAttribute('environment.name', environment.name) + span.setAttribute('keycloak.env_group.ro.id', roGroup.id) + span.setAttribute('keycloak.env_group.rw.id', rwGroup.id) + span.setAttribute('keycloak.env_group.ro.members.current', roMembers.length) + span.setAttribute('keycloak.env_group.rw.members.current', rwMembers.length) + + const projectUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) + span.setAttribute('project.users.count', projectUserIds.size) + + let roAdded = 0 + let roRemoved = 0 + let rwAdded = 0 + let rwRemoved = 0 + + await Promise.all(Array.from(projectUserIds, async (userId) => { + const perms = this.getUserPermissions(project, rolesById, userId) + + const isInRo = roMembers.some(m => m.id === userId) + if (perms.ro && !isInRo) { + roAdded++ + await this.maybeAddUserToGroup(userId, roGroup.id, `RO group for ${environment.name}`) + } else if (!perms.ro && isInRo) { + roRemoved++ + await this.maybeRemoveUserFromGroup(userId, roGroup.id, `RO group for ${environment.name}`) + } + + const isInRw = rwMembers.some(m => m.id === userId) + if (perms.rw && !isInRw) { + rwAdded++ + await this.maybeAddUserToGroup(userId, rwGroup.id, `RW group for ${environment.name}`) + } else if (!perms.rw && isInRw) { + rwRemoved++ + await this.maybeRemoveUserFromGroup(userId, rwGroup.id, `RW group for ${environment.name}`) + } + })) + + span.setAttribute('keycloak.env_group.ro.members.added', roAdded) + span.setAttribute('keycloak.env_group.ro.members.removed', roRemoved) + span.setAttribute('keycloak.env_group.rw.members.added', rwAdded) + span.setAttribute('keycloak.env_group.rw.members.removed', rwRemoved) + + span.end() + } catch (error) { + if (error instanceof Error) span.recordException(error) + span.end() + throw error + } + }) + } + + private async purgeOrphanEnvironmentGroupMembers( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + roGroup: GroupRepresentationWith<'id'>, + rwGroup: GroupRepresentationWith<'id'>, + roMembers: UserRepresentation[], + rwMembers: UserRepresentation[], + ) { + return tracer.startActiveSpan('purgeOrphanEnvironmentGroupMembers', async (span) => { + try { + span.setAttribute('project.slug', project.slug) + span.setAttribute('environment.id', environment.id) + span.setAttribute('environment.name', environment.name) + span.setAttribute('keycloak.env_group.ro.id', roGroup.id) + span.setAttribute('keycloak.env_group.rw.id', rwGroup.id) + span.setAttribute('keycloak.env_group.ro.members.current', roMembers.length) + span.setAttribute('keycloak.env_group.rw.members.current', rwMembers.length) + + const projectUserIds = new Set([project.ownerId, ...project.members.map(m => m.user.id)]) + + let roRemoved = 0 + let rwRemoved = 0 + + await Promise.all([ + ...roMembers.map(async (member) => { + if (!member.id) { + throw new Error(`Failed to create or retrieve RO and RW groups for ${environment.name}`) + } + if (!projectUserIds.has(member.id)) { + roRemoved++ + await this.maybeRemoveUserFromGroup(member.id, roGroup.id, `RO group for ${environment.name}`) + } + }), + ...rwMembers.map(async (member) => { + if (!member.id) { + throw new Error(`Failed to create or retrieve RO and RW groups for ${environment.name}`) + } + if (!projectUserIds.has(member.id)) { + rwRemoved++ + await this.maybeRemoveUserFromGroup(member.id, rwGroup.id, `RW group for ${environment.name}`) + } + }), + ]) + + span.setAttribute('keycloak.env_group.ro.members.removed', roRemoved) + span.setAttribute('keycloak.env_group.rw.members.removed', rwRemoved) + span.end() + } catch (error) { + if (error instanceof Error) span.recordException(error) + span.end() + throw error + } + }) + } + + private async purgeOrphanEnvironmentGroups( + project: ProjectWithDetails, + group: GroupRepresentationWith<'id' | 'name'>, + ) { + const envGroups = map(this.keycloak.getSubGroups(group.id), envGroup => z.object({ + id: z.string(), + name: z.string(), + }).parse(envGroup)) + + const promises: Promise[] = [] + + for await (const envGroup of envGroups) { + const subGroups = await getAll(map(this.keycloak.getSubGroups(envGroup.id), subgroup => z.object({ + name: z.string(), + }).parse(subgroup))) + + if (this.isEnvironmentGroup(subGroups) && !this.isOwnedEnvironmentGroup(project, envGroup)) { + this.logger.log(`Deleting orphan environment group ${envGroup.name} for project ${project.slug}`) + promises.push( + this.keycloak.deleteGroup(envGroup.id) + .catch(e => this.logger.warn(`Failed to delete environment group ${envGroup.name} for project ${project.slug}`, e)), + ) + } + } + await Promise.all(promises) + } + + private isEnvironmentGroup( + subGroups: GroupRepresentationWith<'name'>[], + ) { + return subGroups.some(subgroup => subgroup.name === 'RO' || subgroup.name === 'RW') + } + + private isOwnedEnvironmentGroup( + project: ProjectWithDetails, + group: GroupRepresentationWith<'name'>, + ) { + return project.environments.some(e => e.name === group.name) + } + + private getUserPermissions( + project: ProjectWithDetails, + rolesById: Record, + userId: string, + ) { + if (userId === project.ownerId) return { ro: true, rw: true } + const member = project.members.find(m => m.user.id === userId) + if (!member) return { ro: false, rw: false } + + const projectPermissions = getPermsByUserRoles(member.roleIds, rolesById, project.everyonePerms) + + return { + ro: ProjectAuthorized.ListEnvironments({ adminPermissions: 0n, projectPermissions }), + rw: ProjectAuthorized.ManageEnvironments({ adminPermissions: 0n, projectPermissions }), + } + } +} diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts new file mode 100644 index 000000000..58405b9c0 --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-datastore.service.ts @@ -0,0 +1,50 @@ +import { Inject, Injectable } from '@nestjs/common' +import type { Prisma } from '@prisma/client' +import { PrismaService } from '@/cpin-module/infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + slug: true, + ownerId: true, + everyonePerms: true, + members: { + select: { + roleIds: true, + user: { + select: { + id: true, + email: true, + }, + }, + }, + }, + roles: { + select: { + id: true, + permissions: true, + oidcGroup: true, + type: true, + }, + }, + environments: { + select: { + id: true, + name: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class KeycloakDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + }) + } +} diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.constant.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.constant.ts new file mode 100644 index 000000000..93d81fcf3 --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.constant.ts @@ -0,0 +1 @@ +export const CONSOLE_GROUP_NAME = 'console' diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts new file mode 100644 index 000000000..4275271c2 --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common' +import { KeycloakService } from './keycloak.service' +import { KeycloakControllerService } from './keycloak-controller.service' +import { KeycloakDatastoreService } from './keycloak-datastore.service' +import { KeycloakClientService } from './keycloak-client.service' +import { ConfigurationModule } from '../../cpin-module/infrastructure/configuration/configuration.module' +import { InfrastructureModule } from '../../cpin-module/infrastructure/infrastructure.module' + +@Module({ + imports: [ConfigurationModule, InfrastructureModule], + providers: [KeycloakService, KeycloakControllerService, KeycloakDatastoreService, KeycloakClientService], + exports: [KeycloakService], +}) +export class KeycloakModule {} diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts new file mode 100644 index 000000000..e9d551f2e --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts @@ -0,0 +1,76 @@ +import { Test } from '@nestjs/testing' +import type { TestingModule } from '@nestjs/testing' +import { KeycloakService } from './keycloak.service' +import { KeycloakClientService } from './keycloak-client.service' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { describe, it, expect, beforeEach } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' + +const keycloakMock = mockDeep() + +function createKeycloakServiceTestModule() { + return Test.createTestingModule({ + providers: [ + KeycloakService, + { + provide: KeycloakClientService, + useValue: keycloakMock, + }, + { + provide: ConfigurationService, + useValue: mockDeep(), + }, + ], + }) +} + +describe('keycloak', () => { + let service: KeycloakService + + beforeEach(async () => { + const module: TestingModule = await createKeycloakServiceTestModule().compile() + service = module.get(KeycloakService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('getOrCreateGroupByPath', () => { + it('should return existing group if found by path', async () => { + const groupA: GroupRepresentation = { id: 'id-a', name: 'a' } + const groupB: GroupRepresentation = { id: 'id-b', name: 'b', path: '/a/b' } + + // First call to getGroupByName('a') + keycloakMock.groups.find.mockResolvedValueOnce([groupA]) + + // Call to getSubGroups('id-a') + keycloakMock.groups.listSubGroups.mockResolvedValueOnce([groupB]) + + // When checking 'b', it matches groupB.name + const result = await service.getOrCreateGroupByPath('a/b') + + expect(result).toEqual(groupB) + }) + + it('should create groups if they do not exist', async () => { + // At the first call to getGroupByName('new'), it returns empty + keycloakMock.groups.find.mockResolvedValueOnce([]) + + // Create 'new' group + keycloakMock.groups.find.mockResolvedValueOnce([]) + keycloakMock.groups.create.mockResolvedValue({ id: 'id-new' }) + + // Create 'group' subgroup + keycloakMock.groups.listSubGroups.mockResolvedValueOnce([]) + keycloakMock.groups.createChildGroup.mockResolvedValue({ id: 'id-group' }) + + const result = await service.getOrCreateGroupByPath('new/group') + + expect(result).toEqual({ id: 'id-group', name: 'group', path: 'new/group' }) + expect(keycloakMock.groups.create).toHaveBeenCalledWith({ name: 'new' }) + expect(keycloakMock.groups.createChildGroup).toHaveBeenCalledWith({ id: 'id-new' }, { name: 'group' }) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts new file mode 100644 index 000000000..8c63dd4d4 --- /dev/null +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts @@ -0,0 +1,206 @@ +import { Inject, Injectable, Logger } from '@nestjs/common' +import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/groupRepresentation' +import type { ProjectWithDetails } from './keycloak-datastore.service' +import { CONSOLE_GROUP_NAME } from './keycloak.constant' +import { KeycloakClientService } from './keycloak-client.service' +import { trace } from '@opentelemetry/api' +import type { GroupRepresentationWith } from './keycloack.utils' +import z from 'zod' + +const tracer = trace.getTracer('keycloak-service') + +@Injectable() +export class KeycloakService { + private readonly logger = new Logger(KeycloakService.name) + + constructor( + @Inject(KeycloakClientService) private readonly client: KeycloakClientService, + ) { + } + + async* getAllGroups() { + let first = 0 + while (true) { + const fetched = await this.client.groups.find({ first, max: 50, briefRepresentation: false }) + if (fetched.length === 0) break + for (const group of fetched) { + yield group + } + if (fetched.length < 50) break + first += 50 + } + } + + // TODO: May return undefined if group not found in the most recent search + async getGroupByName(name: string): Promise { + return tracer.startActiveSpan('getGroupByName', async (span) => { + try { + span.setAttribute('group.name', name) + const groups = await this.client.groups.find({ search: name, briefRepresentation: false }) + const result = groups.find(g => g.name === name) + span.end() + return result + } catch (error) { + if (error instanceof Error) span.recordException(error) + span.end() + throw error + } + }) + } + + async getGroupByPath(path: string): Promise { + const parts = path.split('/').filter(Boolean) + let current: GroupRepresentation | undefined + for (const name of parts) { + if (!current) { + const candidates = await this.client.groups.find({ search: name, briefRepresentation: false }) + current = candidates.find(g => g.path === `/${name}`) ?? candidates.find(g => g.name === name) + } else { + for await (const subgroup of this.getSubGroups(current.id!)) { + if (subgroup.name === name) { + current = subgroup + break + } + } + if (current?.name !== name) return undefined + } + if (!current) return undefined + } + return current + } + + async deleteGroup(id: string): Promise { + await this.client.groups.del({ id }) + } + + async getGroupMembers(groupId: string) { + // The type is lying, it can be undefined + const members = await this.client.groups.listMembers({ id: groupId }) + return members || [] + } + + async createGroup(name: string) { + return tracer.startActiveSpan('createGroup', async (span) => { + try { + span.setAttribute('group.name', name) + this.logger.debug(`Creating Keycloak group: ${name}`) + const result = await this.client.groups.create({ name }) + span.end() + return { ...result, name } as GroupRepresentation + } catch (error) { + if (error instanceof Error) span.recordException(error) + span.end() + throw error + } + }) + } + + async addUserToGroup(userId: string, groupId: string) { + return this.client.users.addToGroup({ id: userId, groupId }) + } + + async removeUserFromGroup(userId: string, groupId: string) { + return this.client.users.delFromGroup({ id: userId, groupId }) + } + + async* getSubGroups(parentId: string) { + let first = 0 + while (true) { + const page = await this.client.groups.listSubGroups({ parentId, briefRepresentation: false, max: 10, first }) + if (page.length === 0) break + for (const subgroup of page) { + yield subgroup + } + if (page.length < 10) break + first += 10 + } + } + + async getOrCreateGroupByPath(path: string) { + const existingGroup = await this.getGroupByPath(path) + if (existingGroup) return existingGroup + + const parts = path.split('/').filter(Boolean) + let parentId: string | undefined + let current: GroupRepresentation | undefined + + for (let i = 0; i < parts.length; i++) { + const name = parts[i] + if (!current) { + current = await this.getGroupByName(name) + if (!current) { + current = await this.createGroup(name) + } + } else { + if (!parentId) parentId = current.id! + current = await this.getOrCreateSubGroupByName(parentId, name) + } + parentId = current.id! + } + + return { ...current, path } as GroupRepresentation + } + + async getOrCreateSubGroupByName(parentId: string, name: string) { + return tracer.startActiveSpan('getOrCreateSubGroupByName', async (span) => { + try { + span.setAttribute('group.name', name) + span.setAttribute('parent.id', parentId) + for await (const subgroup of this.getSubGroups(parentId)) { + if (subgroup.name === name) { + span.end() + return subgroup + } + } + this.logger.debug(`Creating SubGroup ${name} under parent ${parentId}`) + const createdGroup = await this.client.groups.createChildGroup({ id: parentId }, { name }) + span.end() + return { id: createdGroup.id, name } satisfies GroupRepresentation + } catch (error) { + if (error instanceof Error) span.recordException(error) + span.end() + throw error + } + }) + } + + async getOrCreateConsoleGroup(projectGroup: GroupRepresentationWith<'id'>) { + return this.getOrCreateSubGroupByName(projectGroup.id, CONSOLE_GROUP_NAME) + } + + async getOrCreateRoleGroup( + consoleGroup: GroupRepresentationWith<'id' | 'name'>, + oidcGroup: string, + ) { + const parts = oidcGroup.split('/').filter(Boolean) + if (parts.length === 0) { + throw new Error(`Invalid oidcGroup for project role: "${oidcGroup}"`) + } + + let current = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.getOrCreateSubGroupByName(consoleGroup.id, parts[0])) + + for (const name of parts.slice(1)) { + current = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.getOrCreateSubGroupByName(current.id, name)) + } + + return { ...current, path: `/${consoleGroup.name}/${parts.join('/')}` } satisfies GroupRepresentation + } + + async getOrCreateEnvironmentGroups(consoleGroup: GroupRepresentationWith<'id'>, environment: ProjectWithDetails['environments'][number]) { + const envGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await this.getOrCreateSubGroupByName(consoleGroup.id, environment.name)) + const [roGroup, rwGroup] = await Promise.all([ + this.getOrCreateSubGroupByName(envGroup.id, 'RO'), + this.getOrCreateSubGroupByName(envGroup.id, 'RW'), + ]) + return { roGroup, rwGroup } + } +} diff --git a/apps/server-nestjs/src/prisma/schema/project.prisma b/apps/server-nestjs/src/prisma/schema/project.prisma index e76048675..833845eee 100644 --- a/apps/server-nestjs/src/prisma/schema/project.prisma +++ b/apps/server-nestjs/src/prisma/schema/project.prisma @@ -65,6 +65,8 @@ model ProjectRole { permissions BigInt projectId String @db.Uuid position Int @db.SmallInt + oidcGroup String @default("") + type String @default("managed") project Project @relation(fields: [projectId], references: [id]) } diff --git a/apps/server-nestjs/test/app.e2e-spec.ts b/apps/server-nestjs/test/app.e2e-spec.ts deleted file mode 100644 index c6472b81c..000000000 --- a/apps/server-nestjs/test/app.e2e-spec.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { INestApplication } from '@nestjs/common' -import type { TestingModule } from '@nestjs/testing' -import { Test } from '@nestjs/testing' -import type { App } from 'supertest/types' - -import { MainModule } from './../src/main.module' - -describe('AppController (e2e)', () => { - let app: INestApplication - - beforeEach(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [MainModule], - }).compile() - - app = moduleFixture.createNestApplication() - await app.init() - }) - - it('should be defined', () => { - expect(app).toBeDefined() - }) -}) diff --git a/apps/server-nestjs/test/keycloak.e2e-spec.ts b/apps/server-nestjs/test/keycloak.e2e-spec.ts new file mode 100644 index 000000000..3c269e55b --- /dev/null +++ b/apps/server-nestjs/test/keycloak.e2e-spec.ts @@ -0,0 +1,531 @@ +import type { TestingModule } from '@nestjs/testing' +import { Test } from '@nestjs/testing' +import { KeycloakModule } from '@/modules/keycloak/keycloak.module' +import { KeycloakControllerService } from '@/modules/keycloak/keycloak-controller.service' +import { projectSelect } from '@/modules/keycloak/keycloak-datastore.service' +import { PrismaService } from '@/cpin-module/infrastructure/database/prisma.service' +import { KeycloakService } from '@/modules/keycloak/keycloak.service' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { ConfigurationModule } from '../src/cpin-module/infrastructure/configuration/configuration.module' +import { Logger } from '@nestjs/common' +import { KeycloakClientService } from '@/modules/keycloak/keycloak-client.service' +import { InfrastructureModule } from '@/cpin-module/infrastructure/infrastructure.module' +import z from 'zod' +import { faker } from '@faker-js/faker' + +const canRunKeycloakE2E + = process.env.E2E === 'true' + && Boolean(process.env.KEYCLOAK_DOMAIN) + && Boolean(process.env.KEYCLOAK_REALM) + && Boolean(process.env.KEYCLOAK_PROTOCOL) + && Boolean(process.env.KEYCLOAK_ADMIN) + && Boolean(process.env.KEYCLOAK_ADMIN_PASSWORD) + +const describeWithKeycloak = describe.runIf(canRunKeycloakE2E) + +describeWithKeycloak('KeycloakController (e2e)', () => { + let moduleRef: TestingModule + let keycloakController: KeycloakControllerService + let keycloakService: KeycloakService + let keycloakClient: KeycloakClientService + let prisma: PrismaService + + let ownerId: string + let testProjectId: string + let testProjectSlug: string + let testRoleName: string + let testRoleId: string + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [KeycloakModule, ConfigurationModule, InfrastructureModule], + }).compile() + + await moduleRef.init() + + keycloakController = moduleRef.get(KeycloakControllerService) + keycloakService = moduleRef.get(KeycloakService) + keycloakClient = moduleRef.get(KeycloakClientService) + prisma = moduleRef.get(PrismaService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + testRoleName = faker.helpers.slugify(`test-role-${faker.string.uuid()}`) + testRoleId = faker.string.uuid() + + const ownerEmail = faker.internet.email({ firstName: 'test-owner', provider: 'example.com' }) + + // Create owner in Keycloak + const createdUser = await keycloakClient.users.create({ + id: ownerId, + username: `test-owner-${ownerId}`, + email: ownerEmail, + enabled: true, + firstName: 'Test', + lastName: 'Owner', + }) + if (createdUser.id) { + ownerId = createdUser.id + } + + // Create owner in DB + await prisma.user.create({ + data: { + id: ownerId, + email: ownerEmail, + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + try { + // Clean Keycloak + const group = await keycloakService.getGroupByPath(`/${testProjectSlug}`) + if (group) { + await keycloakService.deleteGroup(group.id!) + } + + // Clean owner user + if (ownerId) { + await keycloakClient.users.del({ id: ownerId }).catch(() => {}) + if (prisma) { + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + } + + // Clean DB + if (prisma) { + await prisma.projectMembers.deleteMany({ where: { projectId: testProjectId } }) + // Prisma cascade delete should handle roles/envs if configured correctly, but explicit delete is safer + // We catch errors to avoid failing cleanup if tables/relations are different + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + } + } catch (e: any) { + Logger.warn(`Cleanup failed: ${e.message}`) + } + + await moduleRef.close() + + vi.unstubAllEnvs() + }) + + it('should reconcile and create groups in Keycloak', async () => { + // Create Project in DB + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + roles: { + create: { + id: testRoleId, + name: testRoleName, + oidcGroup: `/${testRoleName}`, + permissions: BigInt(0), + position: 0, + }, + }, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act + await keycloakController.handleUpsert(project) + + // Assert + // Check main project group + const projectGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await keycloakService.getGroupByPath(`/${testProjectSlug}`)) + expect(projectGroup.name).toBe(testProjectSlug) + + const consoleGroup = z.object({ + id: z.string(), + name: z.string(), + }).parse(await keycloakService.getGroupByPath(`/${testProjectSlug}/console`)) + expect(consoleGroup.name).toBe('console') + + // Check role group + const roleGroup = z.object({ + name: z.string(), + }).parse(await keycloakService.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + expect(roleGroup.name).toBe(testRoleName) + + // Check membership (owner should be added) + const members = await keycloakService.getGroupMembers(projectGroup.id) + const isMember = members.some(m => m.id === ownerId) + expect(isMember).toBe(true) + }, 60000) + + it('should add member to project group when added in DB', async () => { + // Create another user in Keycloak and DB + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-${newUserId}@example.com` + + // Create in Keycloak + const kcUser = await keycloakClient.users.create({ + username: `test-user-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'User', + }) + + // Create in DB + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'User', + type: 'human', + }, + }) + + // Add member to project in DB + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: kcUser.id, + roleIds: [testRoleId], + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act + await keycloakController.handleUpsert(project) + + // Assert + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloakService.getGroupByPath(`/${testProjectSlug}`)) + const members = await keycloakService.getGroupMembers(projectGroup.id) + const isMember = members.some(m => m.id === kcUser.id) + expect(isMember).toBe(true) + + // Check role group membership + const roleGroup = z.object({ + id: z.string(), + }).parse(await keycloakService.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + const roleMembers = await keycloakService.getGroupMembers(roleGroup.id) + const isRoleMember = roleMembers.some(m => m.id === kcUser.id) + expect(isRoleMember).toBe(true) + + // Cleanup user + await keycloakClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should remove member from project group when removed in DB', async () => { + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-remove-${newUserId}@example.com` + + // Create in Keycloak + const kcUser = await keycloakClient.users.create({ + username: `test-user-remove-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'UserRemove', + }) + + // Create in DB + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'UserRemove', + type: 'human', + }, + }) + + // Add member to project in DB + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: kcUser.id, + roleIds: [], // No roles + }, + }) + + let project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync add + await keycloakController.handleUpsert(project) + + // Verify added + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloakService.getGroupByPath(`/${testProjectSlug}`)) + let members = await keycloakService.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(true) + + // Remove from DB + await prisma.projectMembers.delete({ + where: { + projectId_userId: { + projectId: testProjectId, + userId: kcUser.id, + }, + }, + }) + + project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync remove + await keycloakController.handleUpsert(project) + + // Verify removed + members = await keycloakService.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(false) + + // Cleanup + await keycloakClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should handle non-existent users gracefully', async () => { + // Add a member in DB that does not exist in Keycloak + const fakeUserId = faker.string.uuid() + + await prisma.user.create({ + data: { + id: fakeUserId, + email: `fake-${fakeUserId}@example.com`, + firstName: 'Fake', + lastName: 'User', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: fakeUserId, + roleIds: [], + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act - should not throw + await expect(keycloakController.handleUpsert(project)).resolves.not.toThrow() + + // Cleanup + await prisma.projectMembers.deleteMany({ where: { userId: fakeUserId } }) + await prisma.user.delete({ where: { id: fakeUserId } }) + }, 60000) + + it('should add user back to Keycloak group if missing but present in DB', async () => { + // Create user and add to project in DB + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-sync-${newUserId}@example.com` + + const kcUser = await keycloakClient.users.create({ + username: `test-user-sync-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'UserSync', + }) + + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'UserSync', + type: 'human', + }, + }) + + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: kcUser.id, + roleIds: [], + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync to ensure they are added initially + await keycloakController.handleUpsert(project) + + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloakService.getGroupByPath(`/${testProjectSlug}`)) + + // Manually remove user from Keycloak group + await keycloakService.removeUserFromGroup(kcUser.id, projectGroup.id) + + // Verify removal + let members = await keycloakService.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(false) + + // Sync again + await keycloakController.handleUpsert(project) + + // Verify added back + members = await keycloakService.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(true) + + // Cleanup + await keycloakClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should remove user from Keycloak group if present but missing in DB', async () => { + // Create user + const newUserId = faker.string.uuid() + const newUserEmail = `test-user-orphan-${newUserId}@example.com` + + const kcUser = await keycloakClient.users.create({ + username: `test-user-orphan-${newUserId}`, + email: newUserEmail, + enabled: true, + firstName: 'Test', + lastName: 'UserOrphan', + }) + + // We only need them in Keycloak for this test, but the controller checks if user is in DB to define "missing". + // Actually, `deleteExtraProjectMembers` iterates over Keycloak group members. + // So we don't strictly need the user in DB, but to be "clean" we should probably have them in DB but NOT in the project. + + await prisma.user.create({ + data: { + id: kcUser.id, + email: newUserEmail, + firstName: 'Test', + lastName: 'UserOrphan', + type: 'human', + }, + }) + + // Get project from DB (user is NOT a member) + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Sync to create group + await keycloakController.handleUpsert(project) + + // Manually add user to Keycloak group + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloakService.getGroupByPath(`/${testProjectSlug}`)) + await keycloakService.addUserToGroup(kcUser.id, projectGroup.id) + + // Verify added + let members = await keycloakService.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(true) + + // Sync again to remove user + await keycloakController.handleUpsert(project) + + // Verify removed + members = await keycloakService.getGroupMembers(projectGroup.id) + expect(members.some(m => m.id === kcUser.id)).toBe(false) + + // Cleanup + await keycloakClient.users.del({ id: kcUser.id }) + await prisma.projectMembers.deleteMany({ where: { userId: kcUser.id } }) + await prisma.user.delete({ where: { id: kcUser.id } }) + }, 60000) + + it('should recreate project group if deleted in Keycloak', async () => { + // Ensure project exists and is synced + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + await keycloakController.handleUpsert(project) + + const projectGroup = z.object({ + id: z.string(), + }).parse(await keycloakService.getGroupByPath(`/${testProjectSlug}`)) + + // Delete group in Keycloak + await keycloakService.deleteGroup(projectGroup.id) + + // Verify deleted + const deletedProjectGroup = await keycloakService.getGroupByPath(`/${testProjectSlug}`) + expect(deletedProjectGroup).toBeUndefined() + + // Sync + await keycloakController.handleUpsert(project) + + // Verify recreated + const recreatedProjectGroup = z.object({ + name: z.string(), + }).parse(await keycloakService.getGroupByPath(`/${testProjectSlug}`)) + expect(recreatedProjectGroup?.name).toBe(testProjectSlug) + }, 60000) + + it('should recreate role group if deleted in Keycloak', async () => { + // Ensure project exists and is synced + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + await keycloakController.handleUpsert(project) + + const roleGroup = z.object({ + id: z.string(), + }).parse(await keycloakService.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + + // Delete role group in Keycloak + await keycloakService.deleteGroup(roleGroup.id) + + // Verify deleted + const deletedRoleGroup = await keycloakService.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`) + expect(deletedRoleGroup).toBeUndefined() + + // Sync + await keycloakController.handleUpsert(project) + + // Verify recreated + const recreatedRoleGroup = z.object({ + name: z.string(), + }).parse(await keycloakService.getGroupByPath(`/${testProjectSlug}/console/${testRoleName}`)) + expect(recreatedRoleGroup?.name).toBe(testRoleName) + }, 60000) +}) diff --git a/apps/server-nestjs/tsconfig.json b/apps/server-nestjs/tsconfig.json index ed7656af9..3dd209ffe 100644 --- a/apps/server-nestjs/tsconfig.json +++ b/apps/server-nestjs/tsconfig.json @@ -8,7 +8,9 @@ "module": "nodenext", "moduleResolution": "nodenext", "paths": { - "@/*": ["src/*"] + "@/*": ["src/*", "../../packages/hooks/src/*"], + "@cpn-console/shared": ["../../packages/shared/src/index.ts"], + "@cpn-console/hooks": ["../../packages/hooks/src/index.ts"] }, "resolvePackageJsonExports": true, "strictBindCallApply": false, diff --git a/apps/server-nestjs/vitest.config.ts b/apps/server-nestjs/vitest.config.ts new file mode 100644 index 000000000..e43e55cda --- /dev/null +++ b/apps/server-nestjs/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config' +import path from 'node:path' + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.spec.ts', 'test/**/*.e2e-spec.ts'], + alias: { + '@': path.resolve(__dirname, './src'), + '@cpn-console/shared': path.resolve(__dirname, '../../packages/shared/src/index.ts'), + '@cpn-console/hooks': path.resolve(__dirname, '../../packages/hooks/src/index.ts'), + }, + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + }, + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72e6796a9..b6aeb10fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -353,6 +353,12 @@ importers: apps/server-nestjs: dependencies: + '@casl/ability': + specifier: ^6.7.1 + version: 6.8.0 + '@casl/prisma': + specifier: ^1.5.0 + version: 1.6.1(@casl/ability@6.8.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)) '@cpn-console/argocd-plugin': specifier: workspace:^ version: file:plugins/argocd(@types/node@22.19.15)(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.15)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3))(terser@5.46.0)) @@ -401,6 +407,9 @@ importers: '@gitbeaker/rest': specifier: ^40.6.0 version: 40.6.0 + '@keycloak/keycloak-admin-client': + specifier: ^24.0.0 + version: 24.0.5 '@kubernetes-models/argo-cd': specifier: ^2.7.2 version: 2.7.2 @@ -413,6 +422,9 @@ importers: '@nestjs/core': specifier: ^11.1.16 version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/event-emitter': + specifier: ^3.0.1 + version: 3.0.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) '@nestjs/platform-express': specifier: ^11.1.16 version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) @@ -476,9 +488,15 @@ importers: json-2-csv: specifier: ^5.5.10 version: 5.5.10 + keycloak-connect: + specifier: ^25.0.0 + version: 25.0.6 mustache: specifier: ^4.2.0 version: 4.2.0 + nest-keycloak-connect: + specifier: ^1.10.1 + version: 1.10.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(keycloak-connect@25.0.6) nestjs-pino: specifier: ^4.6.0 version: 4.6.0(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2) @@ -500,6 +518,9 @@ importers: vitest-mock-extended: specifier: ^2.0.2 version: 2.0.2(typescript@5.9.3)(vitest@2.1.9(@types/node@22.19.15)(jsdom@25.0.1)(msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3))(terser@5.46.0)) + zod: + specifier: ^3.25.76 + version: 3.25.76 devDependencies: '@cpn-console/eslint-config': specifier: workspace:^ @@ -549,6 +570,12 @@ importers: globals: specifier: ^16.5.0 version: 16.5.0 + jest: + specifier: ^30.3.0 + version: 30.3.0(@types/node@22.19.15)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)) + msw: + specifier: ^2.12.10 + version: 2.12.10(@types/node@22.19.15)(typescript@5.9.3) nodemon: specifier: ^3.1.14 version: 3.1.14 @@ -1387,6 +1414,27 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-import-assertions@7.28.6': resolution: {integrity: sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==} engines: {node: '>=6.9.0'} @@ -1399,6 +1447,70 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-syntax-unicode-sets-regex@7.18.6': resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} engines: {node: '>=6.9.0'} @@ -1767,6 +1879,15 @@ packages: '@cacheable/utils@2.4.0': resolution: {integrity: sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==} + '@casl/ability@6.8.0': + resolution: {integrity: sha512-Ipt4mzI4gSgnomFdaPjaLgY2MWuXqAEZLrU6qqWBB7khGiBBuuEp6ytYDnq09bRXqcjaeeHiaCvCGFbBA2SpvA==} + + '@casl/prisma@1.6.1': + resolution: {integrity: sha512-VSAzfTMOZvP3Atj3F0qwJItOm1ixIiumjbBz21PL/gLUIDwoktyAx2dB7dPwjH9AQvzZPE629ee7fVU5K2hpzg==} + peerDependencies: + '@casl/ability': ^5.3.0 || ^6.0.0 + '@prisma/client': ^2.14.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@clack/core@1.1.0': resolution: {integrity: sha512-SVcm4Dqm2ukn64/8Gub2wnlA5nS2iWJyCkdNHcvNHPIeBTGojpdJ+9cZKwLfmqy7irD4N5qLteSilJlE0WLAtA==} @@ -2630,10 +2751,96 @@ packages: resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} engines: {node: '>=18'} + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + '@istanbuljs/schema@0.1.3': resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} engines: {node: '>=8'} + '@jest/console@30.3.0': + resolution: {integrity: sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/core@30.3.0': + resolution: {integrity: sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/diff-sequences@30.3.0': + resolution: {integrity: sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/environment@30.3.0': + resolution: {integrity: sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect-utils@30.3.0': + resolution: {integrity: sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect@30.3.0': + resolution: {integrity: sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/fake-timers@30.3.0': + resolution: {integrity: sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/get-type@30.1.0': + resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/globals@30.3.0': + resolution: {integrity: sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/pattern@30.0.1': + resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/reporters@30.3.0': + resolution: {integrity: sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@30.0.5': + resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/snapshot-utils@30.3.0': + resolution: {integrity: sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/source-map@30.0.1': + resolution: {integrity: sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/test-result@30.3.0': + resolution: {integrity: sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/test-sequencer@30.3.0': + resolution: {integrity: sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/transform@30.3.0': + resolution: {integrity: sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/types@30.3.0': + resolution: {integrity: sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -2659,6 +2866,10 @@ packages: '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@keycloak/keycloak-admin-client@24.0.5': + resolution: {integrity: sha512-SXDVtQ3ov7GQbxXq51Uq8lzhwzQwNg6XiY50ZA9whuUe2t/0zPT4Zd/LcULcjweIjSNWWgfbDyN1E3yRSL8Qqw==} + engines: {node: '>=18'} + '@keycloak/keycloak-admin-client@26.5.5': resolution: {integrity: sha512-ZYP1Z+4qZ+vChNKWI+g1X08F2gCpZEWRlEMjwF03xet7bB5j5898nSJNFT1g6XIqVslHq15R8pt55fcULpRuvw==} engines: {node: '>=18'} @@ -2700,6 +2911,9 @@ packages: resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} engines: {node: '>=18'} + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} @@ -2753,6 +2967,12 @@ packages: '@nestjs/websockets': optional: true + '@nestjs/event-emitter@3.0.1': + resolution: {integrity: sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==} + peerDependencies: + '@nestjs/common': ^10.0.0 || ^11.0.0 + '@nestjs/core': ^10.0.0 || ^11.0.0 + '@nestjs/platform-express@11.1.16': resolution: {integrity: sha512-IOegr5+ZfUiMKgk+garsSU4MOkPRhm46e6w8Bp1GcO4vCdl9Piz6FlWAzKVfa/U3Hn/DdzSVJOW3TWcQQFdBDw==} peerDependencies: @@ -3754,6 +3974,9 @@ packages: resolution: {integrity: sha512-KxXvfapcixpz6rVEB6HPjOUZT22yN6v0vI0urQSk1L8MlEWPDFCZkhw2xmkyoTGYeFw7tWTZd7e3lVzRZRN/EA==} engines: {node: '>=18'} + '@sinclair/typebox@0.34.48': + resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==} + '@sindresorhus/base62@1.0.0': resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} engines: {node: '>=18'} @@ -3762,6 +3985,12 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@15.1.1': + resolution: {integrity: sha512-cO5W33JgAPbOh07tvZjUOJ7oWhtaqGHiZw+11DPbyqh2kHTBc3eF/CjJDeQ4205RLQsX6rxCuYOroFQwl7JDRw==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3777,6 +4006,9 @@ packages: '@swc/helpers@0.5.19': resolution: {integrity: sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA==} + '@testim/chrome-version@1.1.4': + resolution: {integrity: sha512-kIhULpw9TrGYnHp/8VfdcneIcxKnLixmADtukQRtJUmsVlMg0niMkwV0xZmi8hqa57xqilIHjWFA0GKvEjVU5g==} + '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} @@ -3784,6 +4016,9 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@tootallnate/quickjs-emscripten@0.23.0': + resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} + '@ts-rest/core@3.52.1': resolution: {integrity: sha512-tAjz7Kxq/grJodcTA1Anop4AVRDlD40fkksEV5Mmal88VoZeRKAG8oMHsDwdwPZz+B/zgnz0q2sF+cm5M7Bc7g==} peerDependencies: @@ -3880,6 +4115,15 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} @@ -3943,6 +4187,9 @@ packages: '@types/sizzle@2.3.10': resolution: {integrity: sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==} + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -3970,6 +4217,12 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + '@types/yauzl@2.10.3': resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} @@ -4038,6 +4291,21 @@ packages: resolution: {integrity: sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ucast/core@1.10.2': + resolution: {integrity: sha512-ons5CwXZ/51wrUPfoduC+cO7AS1/wRb0ybpQJ9RrssossDxVy4t49QxWoWgfBDvVKsz9VXzBk9z0wqTdZ+Cq8g==} + + '@ucast/js@3.1.0': + resolution: {integrity: sha512-eJ7yQeYtMK85UZjxoxBEbTWx6UMxEXKbjVyp+NlzrT5oMKV5Gpo/9bjTl3r7msaXTVC8iD9NJacqJ8yp7joX+Q==} + + '@ucast/mongo2js@1.4.1': + resolution: {integrity: sha512-9aeg5cmqwRQnKCXHN6I17wk83Rcm487bHelaG8T4vfpWneAI469wSI3Srnbu+PuZ5znWRbnwtVq9RgPL+bN6CA==} + + '@ucast/mongo@2.4.3': + resolution: {integrity: sha512-XcI8LclrHWP83H+7H2anGCEeDq0n+12FU2mXCTz6/Tva9/9ddK/iacvvhCyW6cijAAOILmt0tWplRyRhVyZLsA==} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + '@unocss/cli@66.6.6': resolution: {integrity: sha512-78SY8j4hAVelK+vP/adsDGaSjEITasYLFECJLHWxUJSzK+G9UIc5wtL/u4jA+zKvwVkHcDvbkcO5K6wwwpAixg==} engines: {node: '>=14'} @@ -4117,6 +4385,101 @@ packages: peerDependencies: vite: ^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0-0 + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + cpu: [ppc64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + cpu: [s390x] + os: [linux] + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + cpu: [x64] + os: [win32] + '@vitejs/plugin-vue@6.0.4': resolution: {integrity: sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4424,6 +4787,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -4452,6 +4819,9 @@ packages: arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -4486,6 +4856,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-types@0.13.4: + resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} + engines: {node: '>=4'} + astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -4529,6 +4903,20 @@ packages: axios@1.13.6: resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} + babel-jest@30.3.0: + resolution: {integrity: sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-0 + + babel-plugin-istanbul@7.0.1: + resolution: {integrity: sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==} + engines: {node: '>=12'} + + babel-plugin-jest-hoist@30.3.0: + resolution: {integrity: sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + babel-plugin-polyfill-corejs2@0.4.16: resolution: {integrity: sha512-xaVwwSfebXf0ooE11BJovZYKhFjIvQo7TsyVpETuIeH2JHv0k/T6Y5j22pPTvqYqmpkxdlPAJlyJ0tfOJAoMxw==} peerDependencies: @@ -4544,6 +4932,17 @@ packages: peerDependencies: '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-jest@30.3.0: + resolution: {integrity: sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-beta.1 + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -4559,6 +4958,10 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + basic-ftp@5.2.0: + resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} + engines: {node: '>=10.0.0'} + bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} @@ -4610,6 +5013,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} @@ -4684,6 +5090,14 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + camelize-ts@3.0.0: resolution: {integrity: sha512-cgRwKKavoDKLTjO4FQTs3dRBePZp/2Y9Xpud0FhuCOTE86M2cniKN4CCXgRnsyXNMmQMifVHcv6SPaMtTx6ofQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4712,6 +5126,10 @@ packages: change-case@5.4.4: resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} @@ -4738,6 +5156,11 @@ packages: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} + chromedriver@146.0.2: + resolution: {integrity: sha512-/A6ht59pGGrV3bU6eC//yH6W+NRexVGXy/KEe+pNn1MP5Xb34krSA02bGlYuA5XCrfdXPsFI//slvsOBwH//4Q==} + engines: {node: '>=20'} + hasBin: true + ci-info@4.4.0: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} @@ -4813,6 +5236,13 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -4870,6 +5300,9 @@ packages: compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + compare-versions@6.1.1: + resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + component-emitter@1.3.1: resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} @@ -5035,6 +5468,10 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + data-uri-to-buffer@6.0.2: + resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} + engines: {node: '>= 14'} + data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -5071,6 +5508,15 @@ packages: supports-color: optional: true + debug@4.3.1: + resolution: {integrity: sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -5086,6 +5532,14 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + deeks@3.1.0: resolution: {integrity: sha512-e7oWH1LzIdv/prMQ7pmlDlaVoL64glqzvNgkgQNgyec9ORPHrT2jaOqMtRyqJuwWjtfb6v+2rk9pmaHj+F137A==} engines: {node: '>= 16'} @@ -5119,6 +5573,10 @@ packages: defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + degenerator@5.0.1: + resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} + engines: {node: '>= 14'} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -5137,6 +5595,10 @@ packages: destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -5228,6 +5690,10 @@ packages: elliptic@6.6.1: resolution: {integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==} + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -5336,6 +5802,10 @@ packages: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -5344,6 +5814,11 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + eslint-compat-utils@0.5.1: resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} engines: {node: '>=12'} @@ -5592,6 +6067,9 @@ packages: eventemitter2@6.4.7: resolution: {integrity: sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==} + eventemitter2@6.4.9: + resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -5603,14 +6081,26 @@ packages: resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==} engines: {node: '>=10'} + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + executable@4.1.1: resolution: {integrity: sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==} engines: {node: '>=4'} + exit-x@0.2.2: + resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} + engines: {node: '>= 0.8.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + expect@30.3.0: + resolution: {integrity: sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -5707,6 +6197,9 @@ packages: fault@2.0.1: resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fd-slicer@1.1.0: resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} @@ -5757,6 +6250,10 @@ packages: resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} engines: {node: '>=18'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -5848,6 +6345,9 @@ packages: fs-monkey@1.1.0: resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -5899,6 +6399,10 @@ packages: get-own-enumerable-property-symbols@3.0.2: resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -5907,6 +6411,10 @@ packages: resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} engines: {node: '>=8'} + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + get-symbol-description@1.1.0: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} @@ -5914,6 +6422,10 @@ packages: get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + get-uri@6.0.5: + resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} + engines: {node: '>= 14'} + getpass@0.1.7: resolution: {integrity: sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==} @@ -5960,6 +6472,10 @@ packages: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} engines: {node: '>=18'} @@ -6149,6 +6665,10 @@ packages: resolution: {integrity: sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==} engines: {node: '>=8.12.0'} + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + husky@9.1.7: resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} engines: {node: '>=18'} @@ -6193,6 +6713,11 @@ packages: resolution: {integrity: sha512-OnGy+eYT7wVejH2XWgLRgbmzujhhVIATQH0ztIeRilwHBjTeG3pD+XnH3PKX0r9gJ0BuJmJ68q/oh9qgXnNDQg==} engines: {node: '>=18'} + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} @@ -6208,6 +6733,10 @@ packages: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -6235,6 +6764,10 @@ packages: peerDependencies: fp-ts: ^2.5.0 + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + ip-regex@4.3.0: resolution: {integrity: sha512-B9ZWJxHHOHUhUjCPrMpLD4xEq35bUTClHM1S6CBU5ixQnkZmwipwgc96vAd7AAGM9TGHvJR+Uss+/Ak6UphK+Q==} engines: {node: '>=8'} @@ -6306,6 +6839,10 @@ packages: resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==} engines: {node: '>=18'} + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + is-generator-function@1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} @@ -6417,6 +6954,9 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} + is-url@1.2.4: + resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==} + is-weakmap@2.0.2: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} @@ -6429,6 +6969,10 @@ packages: resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} engines: {node: '>= 0.4'} + is2@2.0.9: + resolution: {integrity: sha512-rZkHeBn9Zzq52sd9IUIV3a5mfwBY+o2HePMh0wkGBM4z4qjvy2GwVxQ6nNXSfw6MmVP6gf1QIlWjiOavhM3x5g==} + engines: {node: '>=v0.10.0'} + isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -6449,6 +6993,10 @@ packages: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + istanbul-lib-report@3.0.1: resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} engines: {node: '>=10'} @@ -6480,12 +7028,140 @@ packages: javascript-time-ago@2.6.4: resolution: {integrity: sha512-7K/Z37LuwVaxxjutUDd1pXpznufPcox0b1UYu00ksAMMlV6IsxIvduwL3kgfPxuBVF8jVj7nhrKMPDslMq94aQ==} - jest-worker@27.5.1: - resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} - engines: {node: '>= 10.13.0'} + jest-changed-files@30.3.0: + resolution: {integrity: sha512-B/7Cny6cV5At6M25EWDgf9S617lHivamL8vl6KEpJqkStauzcG4e+WPfDgMMF+H4FVH4A2PLRyvgDJan4441QA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} - jiti@2.6.1: - resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + jest-circus@30.3.0: + resolution: {integrity: sha512-PyXq5szeSfR/4f1lYqCmmQjh0vqDkURUYi9N6whnHjlRz4IUQfMcXkGLeEoiJtxtyPqgUaUUfyQlApXWBSN1RA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-cli@30.3.0: + resolution: {integrity: sha512-l6Tqx+j1fDXJEW5bqYykDQQ7mQg+9mhWXtnj+tQZrTWYHyHoi6Be8HPumDSA+UiX2/2buEgjA58iJzdj146uCw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@30.3.0: + resolution: {integrity: sha512-WPMAkMAtNDY9P/oKObtsRG/6KTrhtgPJoBTmk20uDn4Uy6/3EJnnaZJre/FMT1KVRx8cve1r7/FlMIOfRVWL4w==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@types/node': '*' + esbuild-register: '>=3.4.0' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + esbuild-register: + optional: true + ts-node: + optional: true + + jest-diff@30.3.0: + resolution: {integrity: sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-docblock@30.2.0: + resolution: {integrity: sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-each@30.3.0: + resolution: {integrity: sha512-V8eMndg/aZ+3LnCJgSm13IxS5XSBM22QSZc9BtPK8Dek6pm+hfUNfwBdvsB3d342bo1q7wnSkC38zjX259qZNA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-environment-node@30.3.0: + resolution: {integrity: sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-haste-map@30.3.0: + resolution: {integrity: sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-leak-detector@30.3.0: + resolution: {integrity: sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-matcher-utils@30.3.0: + resolution: {integrity: sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-message-util@30.3.0: + resolution: {integrity: sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-mock@30.3.0: + resolution: {integrity: sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@30.0.1: + resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-resolve-dependencies@30.3.0: + resolution: {integrity: sha512-9ev8s3YN6Hsyz9LV75XUwkCVFlwPbaFn6Wp75qnI0wzAINYWY8Fb3+6y59Rwd3QaS3kKXffHXsZMziMavfz/nw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-resolve@30.3.0: + resolution: {integrity: sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-runner@30.3.0: + resolution: {integrity: sha512-gDv6C9LGKWDPLia9TSzZwf4h3kMQCqyTpq+95PODnTRDO0g9os48XIYYkS6D236vjpBir2fF63YmJFtqkS5Duw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-runtime@30.3.0: + resolution: {integrity: sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-snapshot@30.3.0: + resolution: {integrity: sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-util@30.3.0: + resolution: {integrity: sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-validate@30.3.0: + resolution: {integrity: sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-watcher@30.3.0: + resolution: {integrity: sha512-PJ1d9ThtTR8aMiBWUdcownq9mDdLXsQzJayTk4kmaBRHKvwNQn+ANveuhEBUyNI2hR1TVhvQ8D5kHubbzBHR/w==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + + jest-worker@30.3.0: + resolution: {integrity: sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest@30.3.0: + resolution: {integrity: sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true joycon@3.1.1: @@ -6498,6 +7174,10 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -6600,6 +7280,10 @@ packages: jws@4.0.1: resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + keycloak-connect@25.0.6: + resolution: {integrity: sha512-UbOj4ee2u1LYNff5rkcVuWxc/GTaoga6TKg+/ylJd7djaGh20HVI3qmAVxfGme3BZPIa6/pxEIDpK4KQn+xx1w==} + engines: {node: '>=14'} + keycloak-js@26.2.3: resolution: {integrity: sha512-widjzw/9T6bHRgEp6H/Se3NCCarU7u5CwFKBcwtu7xfA1IfdZb+7Q7/KGusAnBo34Vtls8Oz9vzSqkQvQ7+b4Q==} @@ -6668,6 +7352,10 @@ packages: resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==} engines: {node: '>=14'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -6739,6 +7427,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + luxon@3.5.0: resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} engines: {node: '>=12'} @@ -6765,6 +7457,9 @@ packages: make-error@1.3.6: resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -7029,6 +7724,9 @@ packages: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -7072,6 +7770,11 @@ packages: engines: {node: ^18 || >=20} hasBin: true + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -7090,6 +7793,17 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + nest-keycloak-connect@1.10.1: + resolution: {integrity: sha512-tvAYOTPFnxDnQI06jrtcJa6UhyqVtah6V/XwRrNCCL2mklPYnfllGMgVJX0sc3Mca5yJiTVDZOoWruSxnM5qtg==} + peerDependencies: + '@nestjs/common': '>=6.0.0 <11.0.0' + '@nestjs/core': '>=6.0.0 <11.0.0' + '@nestjs/graphql': '>=6' + keycloak-connect: '>=10.0.0' + peerDependenciesMeta: + '@nestjs/graphql': + optional: true + nestjs-pino@4.6.0: resolution: {integrity: sha512-MzSgnOu9MhRT/f7MsvoDnxat11D9JRJYwL1t+tI6J44UrNz9rUVDpceEh9VFsyfiiIJKUri5S+/snMOoaWh7YA==} engines: {node: '>= 14'} @@ -7099,6 +7813,10 @@ packages: pino-http: ^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 rxjs: ^7.1.0 + netmask@2.0.2: + resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} + engines: {node: '>= 0.4.0'} + nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} @@ -7133,6 +7851,9 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + node-readfiles@0.2.0: resolution: {integrity: sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA==} @@ -7274,10 +7995,18 @@ packages: resolution: {integrity: sha512-4Ispi9I9qYGO4lueiLDhe4q4iK5ERK8reLsuzH6BPaXn53EGaua8H66PXIFGrW897hwjXp+pVLrm/DLxN0RF0A==} engines: {node: '>=12'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -7286,6 +8015,18 @@ packages: resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + pac-proxy-agent@7.2.0: + resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} + engines: {node: '>= 14'} + + pac-resolver@7.0.1: + resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} + engines: {node: '>= 14'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -7331,6 +8072,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + path-key@2.0.1: resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==} engines: {node: '>=4'} @@ -7462,6 +8207,14 @@ packages: resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} hasBin: true + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -7544,6 +8297,10 @@ packages: resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} engines: {node: ^14.13.1 || >=16.0.0} + pretty-format@30.3.0: + resolution: {integrity: sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + prisma@6.19.2: resolution: {integrity: sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==} engines: {node: '>=18.18'} @@ -7579,12 +8336,19 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-agent@6.5.0: + resolution: {integrity: sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==} + engines: {node: '>= 14'} + proxy-from-env@1.0.0: resolution: {integrity: sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==} proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + proxy-from-env@2.0.0: + resolution: {integrity: sha512-h2lD3OfRraP3R51rNFKIE8nX+qoLr1mE74X91YhVxtDbt+OD6ntoNZv56+JgI4RCdtwQ5eexsOk1KdOQDfvPCQ==} + pstree.remy@1.1.8: resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} @@ -7598,6 +8362,9 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + pure-rand@7.0.1: + resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + qified@0.6.0: resolution: {integrity: sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==} engines: {node: '>=20'} @@ -7650,6 +8417,9 @@ packages: resolution: {integrity: sha512-VXUdgSiUrE/WZXn6gUIVVIsg0+Hp6VPZPOaHCay+OuFKy6u/8ktmeNEf+U5qSA8jzGGFsg8jrDNu1BeHpz2pJA==} engines: {node: '>=10'} + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + read-pkg@3.0.0: resolution: {integrity: sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==} engines: {node: '>=4'} @@ -7751,6 +8521,10 @@ packages: resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} engines: {node: '>=18'} + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -8010,6 +8784,10 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + slash@5.1.0: resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} engines: {node: '>=14.16'} @@ -8030,6 +8808,10 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + smob@1.6.1: resolution: {integrity: sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==} engines: {node: '>=20.0.0'} @@ -8038,6 +8820,14 @@ packages: resolution: {integrity: sha512-Gz11jbNU0plrReU9Sj7fmshSBxxJ9ShdD2q4ktHIHo/rpTH6lFyQoYHYKINPJtPe8aHFnsbtW46Ls0tCCBsIZg==} engines: {node: '>=0.10'} + socks-proxy-agent@8.0.5: + resolution: {integrity: sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==} + engines: {node: '>= 14'} + + socks@2.8.7: + resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -8045,6 +8835,9 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} @@ -8088,11 +8881,18 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sshpk@1.18.0: resolution: {integrity: sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==} engines: {node: '>=0.10.0'} hasBin: true + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -8125,6 +8925,10 @@ packages: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -8183,6 +8987,10 @@ packages: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + strip-comments@2.0.1: resolution: {integrity: sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==} engines: {node: '>=10'} @@ -8323,6 +9131,9 @@ packages: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + tcp-port-used@1.0.2: + resolution: {integrity: sha512-l7ar8lLUD3XS1V2lfoJlCBaeoaWo/2xfYt81hM7VlvR4RrMVFqfmzfhLVk40hAb368uitje5gPtBRL1m/DGvLA==} + temp-dir@2.0.0: resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} engines: {node: '>=8'} @@ -8352,6 +9163,10 @@ packages: engines: {node: '>=10'} hasBin: true + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + test-exclude@7.0.2: resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} engines: {node: '>=18'} @@ -8416,6 +9231,9 @@ packages: resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -8542,6 +9360,10 @@ packages: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + type-fest@0.16.0: resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} engines: {node: '>=10'} @@ -8746,6 +9568,9 @@ packages: resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} engines: {node: '>=18.12.0'} + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + until-async@3.0.2: resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} @@ -8766,6 +9591,10 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-join@5.0.0: + resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + url-template@3.1.1: resolution: {integrity: sha512-4oszoaEKE/mQOtAmdMWqIRHmkxWkUZMnXFnjQ5i01CuRSK3uluxcH1MRVVVWmhlnzT1SCDfKxxficm2G37qzCA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -8784,6 +9613,10 @@ packages: v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -8962,6 +9795,9 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + watchpack@2.5.1: resolution: {integrity: sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==} engines: {node: '>=10.13.0'} @@ -9133,6 +9969,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + write-file-atomic@7.0.1: resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} engines: {node: ^20.17.0 || >=22.9.0} @@ -9592,6 +10432,26 @@ snapshots: dependencies: '@babel/core': 7.29.0 + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-import-assertions@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -9602,6 +10462,66 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -10071,6 +10991,17 @@ snapshots: hashery: 1.5.0 keyv: 5.6.0 + '@casl/ability@6.8.0': + dependencies: + '@ucast/mongo2js': 1.4.1 + + '@casl/prisma@1.6.1(@casl/ability@6.8.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))': + dependencies: + '@casl/ability': 6.8.0 + '@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) + '@ucast/core': 1.10.2 + '@ucast/js': 3.1.0 + '@clack/core@1.1.0': dependencies: sisteransi: 1.0.5 @@ -11011,62 +11942,253 @@ snapshots: '@isaacs/cliui@9.0.0': {} - '@istanbuljs/schema@0.1.3': {} - - '@jridgewell/gen-mapping@0.3.13': + '@istanbuljs/load-nyc-config@1.1.0': dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.2 + resolve-from: 5.0.0 - '@jridgewell/remapping@2.3.5': + '@istanbuljs/schema@0.1.3': {} + + '@jest/console@30.3.0': dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 + '@jest/types': 30.3.0 + '@types/node': 24.12.0 + chalk: 4.1.2 + jest-message-util: 30.3.0 + jest-util: 30.3.0 + slash: 3.0.0 + + '@jest/core@30.3.0(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3))': + dependencies: + '@jest/console': 30.3.0 + '@jest/pattern': 30.0.1 + '@jest/reporters': 30.3.0 + '@jest/test-result': 30.3.0 + '@jest/transform': 30.3.0 + '@jest/types': 30.3.0 + '@types/node': 24.12.0 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 4.4.0 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-changed-files: 30.3.0 + jest-config: 30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)) + jest-haste-map: 30.3.0 + jest-message-util: 30.3.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.3.0 + jest-resolve-dependencies: 30.3.0 + jest-runner: 30.3.0 + jest-runtime: 30.3.0 + jest-snapshot: 30.3.0 + jest-util: 30.3.0 + jest-validate: 30.3.0 + jest-watcher: 30.3.0 + pretty-format: 30.3.0 + slash: 3.0.0 + transitivePeerDependencies: + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node - '@jridgewell/resolve-uri@3.1.2': {} + '@jest/diff-sequences@30.3.0': {} - '@jridgewell/source-map@0.3.11': + '@jest/environment@30.3.0': dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 + '@jest/fake-timers': 30.3.0 + '@jest/types': 30.3.0 + '@types/node': 24.12.0 + jest-mock: 30.3.0 - '@jridgewell/sourcemap-codec@1.5.5': {} + '@jest/expect-utils@30.3.0': + dependencies: + '@jest/get-type': 30.1.0 - '@jridgewell/trace-mapping@0.3.31': + '@jest/expect@30.3.0': dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + expect: 30.3.0 + jest-snapshot: 30.3.0 + transitivePeerDependencies: + - supports-color - '@jridgewell/trace-mapping@0.3.9': + '@jest/fake-timers@30.3.0': dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@jest/types': 30.3.0 + '@sinonjs/fake-timers': 15.1.1 + '@types/node': 24.12.0 + jest-message-util: 30.3.0 + jest-mock: 30.3.0 + jest-util: 30.3.0 - '@js-sdsl/ordered-map@4.4.2': {} + '@jest/get-type@30.1.0': {} - '@keycloak/keycloak-admin-client@26.5.5': + '@jest/globals@30.3.0': dependencies: - camelize-ts: 3.0.0 - url-template: 3.1.1 + '@jest/environment': 30.3.0 + '@jest/expect': 30.3.0 + '@jest/types': 30.3.0 + jest-mock: 30.3.0 + transitivePeerDependencies: + - supports-color - '@keyv/bigmap@1.3.1(keyv@5.6.0)': + '@jest/pattern@30.0.1': dependencies: - hashery: 1.5.0 - hookified: 1.15.1 - keyv: 5.6.0 + '@types/node': 24.12.0 + jest-regex-util: 30.0.1 - '@keyv/serialize@1.1.1': {} + '@jest/reporters@30.3.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 30.3.0 + '@jest/test-result': 30.3.0 + '@jest/transform': 30.3.0 + '@jest/types': 30.3.0 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 24.12.0 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit-x: 0.2.2 + glob: 10.5.0 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + jest-message-util: 30.3.0 + jest-util: 30.3.0 + jest-worker: 30.3.0 + slash: 3.0.0 + string-length: 4.0.2 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color - '@kubernetes-models/apimachinery@2.2.0': + '@jest/schemas@30.0.5': dependencies: - '@kubernetes-models/base': 5.0.1 - '@kubernetes-models/validate': 4.0.0 - '@swc/helpers': 0.5.19 + '@sinclair/typebox': 0.34.48 - '@kubernetes-models/argo-cd@2.7.2': + '@jest/snapshot-utils@30.3.0': dependencies: - '@kubernetes-models/apimachinery': 2.2.0 - '@kubernetes-models/base': 5.0.1 + '@jest/types': 30.3.0 + chalk: 4.1.2 + graceful-fs: 4.2.11 + natural-compare: 1.4.0 + + '@jest/source-map@30.0.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@30.3.0': + dependencies: + '@jest/console': 30.3.0 + '@jest/types': 30.3.0 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + + '@jest/test-sequencer@30.3.0': + dependencies: + '@jest/test-result': 30.3.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.3.0 + slash: 3.0.0 + + '@jest/transform@30.3.0': + dependencies: + '@babel/core': 7.29.0 + '@jest/types': 30.3.0 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 7.0.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.3.0 + jest-regex-util: 30.0.1 + jest-util: 30.3.0 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + + '@jest/types@30.3.0': + dependencies: + '@jest/pattern': 30.0.1 + '@jest/schemas': 30.0.5 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 24.12.0 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@js-sdsl/ordered-map@4.4.2': {} + + '@keycloak/keycloak-admin-client@24.0.5': + dependencies: + camelize-ts: 3.0.0 + url-join: 5.0.0 + url-template: 3.1.1 + + '@keycloak/keycloak-admin-client@26.5.5': + dependencies: + camelize-ts: 3.0.0 + url-template: 3.1.1 + + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.0 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + + '@kubernetes-models/apimachinery@2.2.0': + dependencies: + '@kubernetes-models/base': 5.0.1 + '@kubernetes-models/validate': 4.0.0 + '@swc/helpers': 0.5.19 + + '@kubernetes-models/argo-cd@2.7.2': + dependencies: + '@kubernetes-models/apimachinery': 2.2.0 + '@kubernetes-models/base': 5.0.1 '@kubernetes-models/validate': 4.0.0 '@swc/helpers': 0.5.19 @@ -11099,6 +12221,12 @@ snapshots: is-node-process: 1.2.0 outvariant: 1.4.3 strict-event-emitter: 0.5.1 + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 + '@tybys/wasm-util': 0.10.1 optional: true '@napi-rs/wasm-runtime@1.1.1': @@ -11168,6 +12296,12 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + eventemitter2: 6.4.9 + '@nestjs/platform-express@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)': dependencies: '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -11223,17 +12357,14 @@ snapshots: dependencies: consola: 3.4.2 - '@open-draft/deferred-promise@2.2.0': - optional: true + '@open-draft/deferred-promise@2.2.0': {} '@open-draft/logger@0.3.0': dependencies: is-node-process: 1.2.0 outvariant: 1.4.3 - optional: true - '@open-draft/until@2.1.0': - optional: true + '@open-draft/until@2.1.0': {} '@opentelemetry/api-logs@0.212.0': dependencies: @@ -12302,10 +13433,20 @@ snapshots: '@simple-libs/stream-utils@1.2.0': {} + '@sinclair/typebox@0.34.48': {} + '@sindresorhus/base62@1.0.0': {} '@sindresorhus/merge-streams@4.0.0': {} + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@15.1.1': + dependencies: + '@sinonjs/commons': 3.0.1 + '@standard-schema/spec@1.1.0': {} '@stylistic/eslint-plugin@5.10.0(eslint@10.0.3(jiti@2.6.1))': @@ -12329,6 +13470,9 @@ snapshots: dependencies: tslib: 2.8.1 + '@testim/chrome-version@1.1.4': + optional: true + '@tokenizer/inflate@0.4.1': dependencies: debug: 4.4.3 @@ -12338,6 +13482,9 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@tootallnate/quickjs-emscripten@0.23.0': + optional: true + '@ts-rest/core@3.52.1(@types/node@22.19.15)(zod@3.25.76)': optionalDependencies: '@types/node': 22.19.15 @@ -12398,23 +13545,19 @@ snapshots: '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 - optional: true '@types/babel__generator@7.27.0': dependencies: '@babel/types': 7.29.0 - optional: true '@types/babel__template@7.4.4': dependencies: '@babel/parser': 7.29.0 '@babel/types': 7.29.0 - optional: true '@types/babel__traverse@7.28.0': dependencies: '@babel/types': 7.29.0 - optional: true '@types/body-parser@1.19.6': dependencies: @@ -12466,6 +13609,16 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + '@types/js-yaml@4.0.9': {} '@types/jsdom@21.1.7': @@ -12537,8 +13690,9 @@ snapshots: '@types/sizzle@2.3.10': optional: true - '@types/statuses@2.0.6': - optional: true + '@types/stack-utils@2.0.3': {} + + '@types/statuses@2.0.6': {} '@types/superagent@8.1.9': dependencies: @@ -12567,6 +13721,12 @@ snapshots: '@types/unist@3.0.3': {} + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + '@types/yauzl@2.10.3': dependencies: '@types/node': 24.12.0 @@ -12728,6 +13888,24 @@ snapshots: '@typescript-eslint/types': 8.57.0 eslint-visitor-keys: 5.0.1 + '@ucast/core@1.10.2': {} + + '@ucast/js@3.1.0': + dependencies: + '@ucast/core': 1.10.2 + + '@ucast/mongo2js@1.4.1': + dependencies: + '@ucast/core': 1.10.2 + '@ucast/js': 3.1.0 + '@ucast/mongo': 2.4.3 + + '@ucast/mongo@2.4.3': + dependencies: + '@ucast/core': 1.10.2 + + '@ungap/structured-clone@1.3.0': {} + '@unocss/cli@66.6.6': dependencies: '@jridgewell/remapping': 2.3.5 @@ -12870,6 +14048,65 @@ snapshots: unplugin-utils: 0.3.1 vite: 7.3.1(@types/node@24.12.0)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2) + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + optional: true + + '@unrs/resolver-binding-android-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + optional: true + '@vitejs/plugin-vue@6.0.4(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.30(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 @@ -13282,7 +14519,6 @@ snapshots: ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 - optional: true ansi-escapes@7.3.0: dependencies: @@ -13300,6 +14536,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} ansis@4.2.0: {} @@ -13320,6 +14558,10 @@ snapshots: arg@4.1.3: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} array-buffer-byte-length@1.0.2: @@ -13360,6 +14602,11 @@ snapshots: assertion-error@2.0.1: {} + ast-types@0.13.4: + dependencies: + tslib: 2.8.1 + optional: true + astral-regex@2.0.0: {} async-function@1.0.0: {} @@ -13400,6 +14647,33 @@ snapshots: transitivePeerDependencies: - debug + babel-jest@30.3.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@jest/transform': 30.3.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 7.0.1 + babel-preset-jest: 30.3.0(@babel/core@7.29.0) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@7.0.1: + dependencies: + '@babel/helper-plugin-utils': 7.28.6 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 6.0.3 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@30.3.0: + dependencies: + '@types/babel__core': 7.20.5 + babel-plugin-polyfill-corejs2@0.4.16(@babel/core@7.29.0): dependencies: '@babel/compat-data': 7.29.0 @@ -13424,6 +14698,31 @@ snapshots: transitivePeerDependencies: - supports-color + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.0) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.0) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.0) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.0) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.0) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.0) + + babel-preset-jest@30.3.0(@babel/core@7.29.0): + dependencies: + '@babel/core': 7.29.0 + babel-plugin-jest-hoist: 30.3.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + balanced-match@1.0.2: {} balanced-match@4.0.4: {} @@ -13432,6 +14731,9 @@ snapshots: baseline-browser-mapping@2.10.0: {} + basic-ftp@5.2.0: + optional: true + bcrypt-pbkdf@1.0.2: dependencies: tweetnacl: 0.14.5 @@ -13488,8 +14790,7 @@ snapshots: dependencies: fill-range: 7.1.1 - brorand@1.1.0: - optional: true + brorand@1.1.0: {} browserslist@4.28.1: dependencies: @@ -13499,6 +14800,10 @@ snapshots: node-releases: 2.0.36 update-browserslist-db: 1.2.3(browserslist@4.28.1) + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + buffer-crc32@0.2.13: optional: true @@ -13590,6 +14895,10 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + camelize-ts@3.0.0: {} caniuse-lite@1.0.30001777: {} @@ -13620,6 +14929,8 @@ snapshots: change-case@5.4.4: {} + char-regex@1.0.2: {} + character-entities@2.0.2: {} chardet@2.1.1: {} @@ -13648,6 +14959,20 @@ snapshots: chrome-trace-event@1.0.4: {} + chromedriver@146.0.2: + dependencies: + '@testim/chrome-version': 1.1.4 + axios: 1.13.6 + compare-versions: 6.1.1 + extract-zip: 2.0.1 + proxy-agent: 6.5.0 + proxy-from-env: 2.0.0 + tcp-port-used: 1.0.2 + transitivePeerDependencies: + - debug + - supports-color + optional: true + ci-info@4.4.0: {} cidr-regex@3.1.1: @@ -13725,6 +15050,10 @@ snapshots: clsx@2.1.1: {} + co@4.6.0: {} + + collect-v8-coverage@1.0.3: {} + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -13772,6 +15101,9 @@ snapshots: array-ify: 1.0.0 dot-prop: 5.3.0 + compare-versions@6.1.1: + optional: true + component-emitter@1.3.1: {} concat-map@0.0.1: {} @@ -13816,8 +15148,7 @@ snapshots: cookie@0.7.2: {} - cookie@1.1.1: - optional: true + cookie@1.1.1: {} cookiejar@2.1.4: {} @@ -13966,6 +15297,9 @@ snapshots: data-uri-to-buffer@4.0.1: {} + data-uri-to-buffer@6.0.2: + optional: true + data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -14004,6 +15338,11 @@ snapshots: supports-color: 8.1.1 optional: true + debug@4.3.1: + dependencies: + ms: 2.1.2 + optional: true + debug@4.4.3: dependencies: ms: 2.1.3 @@ -14027,6 +15366,8 @@ snapshots: dependencies: character-entities: 2.0.2 + dedent@1.7.2: {} + deeks@3.1.0: {} deep-eql@5.0.2: {} @@ -14055,6 +15396,13 @@ snapshots: defu@6.1.4: {} + degenerator@5.0.1: + dependencies: + ast-types: 0.13.4 + escodegen: 2.1.0 + esprima: 4.0.1 + optional: true + delayed-stream@1.0.0: {} delegate@3.2.0: {} @@ -14065,6 +15413,8 @@ snapshots: destr@2.0.5: {} + detect-newline@3.1.0: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -14160,7 +15510,8 @@ snapshots: inherits: 2.0.4 minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 - optional: true + + emittery@0.13.1: {} emoji-regex@10.6.0: {} @@ -14348,10 +15699,21 @@ snapshots: escape-string-regexp@1.0.5: {} + escape-string-regexp@2.0.0: {} + escape-string-regexp@4.0.0: {} escape-string-regexp@5.0.0: {} + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + optional: true + eslint-compat-utils@0.5.1(eslint@10.0.3(jiti@2.6.1)): dependencies: eslint: 10.0.3(jiti@2.6.1) @@ -14709,6 +16071,8 @@ snapshots: eventemitter2@6.4.7: optional: true + eventemitter2@6.4.9: {} + eventemitter3@5.0.4: {} events@3.3.0: {} @@ -14726,13 +16090,36 @@ snapshots: strip-final-newline: 2.0.0 optional: true + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + executable@4.1.1: dependencies: pify: 2.3.0 optional: true + exit-x@0.2.2: {} + expect-type@1.3.0: {} + expect@30.3.0: + dependencies: + '@jest/expect-utils': 30.3.0 + '@jest/get-type': 30.1.0 + jest-matcher-utils: 30.3.0 + jest-message-util: 30.3.0 + jest-mock: 30.3.0 + jest-util: 30.3.0 + express@5.2.1: dependencies: accepts: 2.0.0 @@ -14770,6 +16157,17 @@ snapshots: extend@3.0.2: {} + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + optional: true + extract-zip@2.0.1(supports-color@8.1.1): dependencies: debug: 4.4.3(supports-color@8.1.1) @@ -14898,6 +16296,10 @@ snapshots: dependencies: format: 0.2.2 + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + fd-slicer@1.1.0: dependencies: pend: 1.2.0 @@ -14961,6 +16363,11 @@ snapshots: find-up-simple@1.0.1: {} + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -15062,6 +16469,8 @@ snapshots: fs-monkey@1.1.0: {} + fs.realpath@1.0.0: {} + fsevents@2.3.2: optional: true @@ -15120,6 +16529,8 @@ snapshots: get-own-enumerable-property-symbols@3.0.2: {} + get-package-type@0.1.0: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -15130,6 +16541,8 @@ snapshots: pump: 3.0.4 optional: true + get-stream@6.0.1: {} + get-symbol-description@1.1.0: dependencies: call-bound: 1.0.4 @@ -15140,6 +16553,15 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-uri@6.0.5: + dependencies: + basic-ftp: 5.2.0 + data-uri-to-buffer: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + optional: true + getpass@0.1.7: dependencies: assert-plus: 1.0.0 @@ -15202,6 +16624,15 @@ snapshots: minipass: 7.1.3 path-scurry: 2.0.2 + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + global-directory@4.0.1: dependencies: ini: 4.1.1 @@ -15274,8 +16705,7 @@ snapshots: jwk-to-pem: 2.0.7 jws: 4.0.1 - graphql@16.13.1: - optional: true + graphql@16.13.1: {} gzip-size@6.0.0: dependencies: @@ -15307,7 +16737,6 @@ snapshots: dependencies: inherits: 2.0.4 minimalistic-assert: 1.0.1 - optional: true hasha@5.2.2: dependencies: @@ -15325,8 +16754,7 @@ snapshots: he@1.2.0: {} - headers-polyfill@4.0.3: - optional: true + headers-polyfill@4.0.3: {} helmet@7.2.0: {} @@ -15337,7 +16765,6 @@ snapshots: hash.js: 1.1.7 minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 - optional: true hookified@1.15.1: {} @@ -15402,6 +16829,8 @@ snapshots: human-signals@1.1.1: optional: true + human-signals@2.1.0: {} + husky@9.1.7: {} iconv-lite@0.6.3: @@ -15443,6 +16872,11 @@ snapshots: cjs-module-lexer: 2.2.0 module-details-from-path: 1.0.4 + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + import-meta-resolve@4.2.0: {} imurmurhash@0.1.4: {} @@ -15452,6 +16886,11 @@ snapshots: indent-string@5.0.0: {} + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + inherits@2.0.4: {} ini@1.3.8: {} @@ -15473,6 +16912,9 @@ snapshots: dependencies: fp-ts: 2.16.11 + ip-address@10.1.0: + optional: true + ip-regex@4.3.0: {} ipaddr.js@1.9.1: {} @@ -15543,6 +16985,8 @@ snapshots: dependencies: get-east-asian-width: 1.5.0 + is-generator-fn@2.1.0: {} + is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 @@ -15569,8 +17013,7 @@ snapshots: is-negative-zero@2.0.3: {} - is-node-process@1.2.0: - optional: true + is-node-process@1.2.0: {} is-number-object@1.1.1: dependencies: @@ -15635,6 +17078,9 @@ snapshots: is-unicode-supported@0.1.0: {} + is-url@1.2.4: + optional: true + is-weakmap@2.0.2: {} is-weakref@1.1.1: @@ -15646,6 +17092,13 @@ snapshots: call-bound: 1.0.4 get-intrinsic: 1.3.0 + is2@2.0.9: + dependencies: + deep-is: 0.1.4 + ip-regex: 4.3.0 + is-url: 1.2.4 + optional: true + isarray@1.0.0: {} isarray@2.0.5: {} @@ -15659,6 +17112,16 @@ snapshots: istanbul-lib-coverage@3.2.2: {} + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + istanbul-lib-report@3.0.1: dependencies: istanbul-lib-coverage: 3.2.2 @@ -15700,12 +17163,355 @@ snapshots: dependencies: relative-time-format: 1.1.12 + jest-changed-files@30.3.0: + dependencies: + execa: 5.1.1 + jest-util: 30.3.0 + p-limit: 3.1.0 + + jest-circus@30.3.0: + dependencies: + '@jest/environment': 30.3.0 + '@jest/expect': 30.3.0 + '@jest/test-result': 30.3.0 + '@jest/types': 30.3.0 + '@types/node': 24.12.0 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.2 + is-generator-fn: 2.1.0 + jest-each: 30.3.0 + jest-matcher-utils: 30.3.0 + jest-message-util: 30.3.0 + jest-runtime: 30.3.0 + jest-snapshot: 30.3.0 + jest-util: 30.3.0 + p-limit: 3.1.0 + pretty-format: 30.3.0 + pure-rand: 7.0.1 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@30.3.0(@types/node@22.19.15)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)): + dependencies: + '@jest/core': 30.3.0(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)) + '@jest/test-result': 30.3.0 + '@jest/types': 30.3.0 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.3.0(@types/node@22.19.15)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)) + jest-util: 30.3.0 + jest-validate: 30.3.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + jest-config@30.3.0(@types/node@22.19.15)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)): + dependencies: + '@babel/core': 7.29.0 + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.0.1 + '@jest/test-sequencer': 30.3.0 + '@jest/types': 30.3.0 + babel-jest: 30.3.0(@babel/core@7.29.0) + chalk: 4.1.2 + ci-info: 4.4.0 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.3.0 + jest-docblock: 30.2.0 + jest-environment-node: 30.3.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.3.0 + jest-runner: 30.3.0 + jest-util: 30.3.0 + jest-validate: 30.3.0 + parse-json: 5.2.0 + pretty-format: 30.3.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.19.15 + ts-node: 10.9.2(@types/node@22.19.15)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-config@30.3.0(@types/node@24.12.0)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)): + dependencies: + '@babel/core': 7.29.0 + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.0.1 + '@jest/test-sequencer': 30.3.0 + '@jest/types': 30.3.0 + babel-jest: 30.3.0(@babel/core@7.29.0) + chalk: 4.1.2 + ci-info: 4.4.0 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.3.0 + jest-docblock: 30.2.0 + jest-environment-node: 30.3.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.3.0 + jest-runner: 30.3.0 + jest-util: 30.3.0 + jest-validate: 30.3.0 + parse-json: 5.2.0 + pretty-format: 30.3.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 24.12.0 + ts-node: 10.9.2(@types/node@22.19.15)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@30.3.0: + dependencies: + '@jest/diff-sequences': 30.3.0 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.3.0 + + jest-docblock@30.2.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@30.3.0: + dependencies: + '@jest/get-type': 30.1.0 + '@jest/types': 30.3.0 + chalk: 4.1.2 + jest-util: 30.3.0 + pretty-format: 30.3.0 + + jest-environment-node@30.3.0: + dependencies: + '@jest/environment': 30.3.0 + '@jest/fake-timers': 30.3.0 + '@jest/types': 30.3.0 + '@types/node': 24.12.0 + jest-mock: 30.3.0 + jest-util: 30.3.0 + jest-validate: 30.3.0 + + jest-haste-map@30.3.0: + dependencies: + '@jest/types': 30.3.0 + '@types/node': 24.12.0 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 30.0.1 + jest-util: 30.3.0 + jest-worker: 30.3.0 + picomatch: 4.0.3 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@30.3.0: + dependencies: + '@jest/get-type': 30.1.0 + pretty-format: 30.3.0 + + jest-matcher-utils@30.3.0: + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.3.0 + pretty-format: 30.3.0 + + jest-message-util@30.3.0: + dependencies: + '@babel/code-frame': 7.29.0 + '@jest/types': 30.3.0 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + picomatch: 4.0.3 + pretty-format: 30.3.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@30.3.0: + dependencies: + '@jest/types': 30.3.0 + '@types/node': 24.12.0 + jest-util: 30.3.0 + + jest-pnp-resolver@1.2.3(jest-resolve@30.3.0): + optionalDependencies: + jest-resolve: 30.3.0 + + jest-regex-util@30.0.1: {} + + jest-resolve-dependencies@30.3.0: + dependencies: + jest-regex-util: 30.0.1 + jest-snapshot: 30.3.0 + transitivePeerDependencies: + - supports-color + + jest-resolve@30.3.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 30.3.0 + jest-pnp-resolver: 1.2.3(jest-resolve@30.3.0) + jest-util: 30.3.0 + jest-validate: 30.3.0 + slash: 3.0.0 + unrs-resolver: 1.11.1 + + jest-runner@30.3.0: + dependencies: + '@jest/console': 30.3.0 + '@jest/environment': 30.3.0 + '@jest/test-result': 30.3.0 + '@jest/transform': 30.3.0 + '@jest/types': 30.3.0 + '@types/node': 24.12.0 + chalk: 4.1.2 + emittery: 0.13.1 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-docblock: 30.2.0 + jest-environment-node: 30.3.0 + jest-haste-map: 30.3.0 + jest-leak-detector: 30.3.0 + jest-message-util: 30.3.0 + jest-resolve: 30.3.0 + jest-runtime: 30.3.0 + jest-util: 30.3.0 + jest-watcher: 30.3.0 + jest-worker: 30.3.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@30.3.0: + dependencies: + '@jest/environment': 30.3.0 + '@jest/fake-timers': 30.3.0 + '@jest/globals': 30.3.0 + '@jest/source-map': 30.0.1 + '@jest/test-result': 30.3.0 + '@jest/transform': 30.3.0 + '@jest/types': 30.3.0 + '@types/node': 24.12.0 + chalk: 4.1.2 + cjs-module-lexer: 2.2.0 + collect-v8-coverage: 1.0.3 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.3.0 + jest-message-util: 30.3.0 + jest-mock: 30.3.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.3.0 + jest-snapshot: 30.3.0 + jest-util: 30.3.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@30.3.0: + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/types': 7.29.0 + '@jest/expect-utils': 30.3.0 + '@jest/get-type': 30.1.0 + '@jest/snapshot-utils': 30.3.0 + '@jest/transform': 30.3.0 + '@jest/types': 30.3.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.0) + chalk: 4.1.2 + expect: 30.3.0 + graceful-fs: 4.2.11 + jest-diff: 30.3.0 + jest-matcher-utils: 30.3.0 + jest-message-util: 30.3.0 + jest-util: 30.3.0 + pretty-format: 30.3.0 + semver: 7.7.4 + synckit: 0.11.12 + transitivePeerDependencies: + - supports-color + + jest-util@30.3.0: + dependencies: + '@jest/types': 30.3.0 + '@types/node': 24.12.0 + chalk: 4.1.2 + ci-info: 4.4.0 + graceful-fs: 4.2.11 + picomatch: 4.0.3 + + jest-validate@30.3.0: + dependencies: + '@jest/get-type': 30.1.0 + '@jest/types': 30.3.0 + camelcase: 6.3.0 + chalk: 4.1.2 + leven: 3.1.0 + pretty-format: 30.3.0 + + jest-watcher@30.3.0: + dependencies: + '@jest/test-result': 30.3.0 + '@jest/types': 30.3.0 + '@types/node': 24.12.0 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 30.3.0 + string-length: 4.0.2 + jest-worker@27.5.1: dependencies: '@types/node': 22.19.15 merge-stream: 2.0.0 supports-color: 8.1.1 + jest-worker@30.3.0: + dependencies: + '@types/node': 24.12.0 + '@ungap/structured-clone': 1.3.0 + jest-util: 30.3.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@30.3.0(@types/node@22.19.15)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)): + dependencies: + '@jest/core': 30.3.0(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)) + '@jest/types': 30.3.0 + import-local: 3.2.0 + jest-cli: 30.3.0(@types/node@22.19.15)(ts-node@10.9.2(@types/node@22.19.15)(typescript@5.9.3)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jiti@2.6.1: {} joycon@3.1.1: {} @@ -15714,6 +17520,11 @@ snapshots: js-tokens@9.0.1: {} + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -15840,7 +17651,6 @@ snapshots: asn1.js: 5.4.1 elliptic: 6.6.1 safe-buffer: 5.2.1 - optional: true jws@4.0.1: dependencies: @@ -15848,6 +17658,15 @@ snapshots: safe-buffer: 5.2.1 optional: true + keycloak-connect@25.0.6: + dependencies: + jwk-to-pem: 2.0.7 + optionalDependencies: + chromedriver: 146.0.2 + transitivePeerDependencies: + - debug + - supports-color + keycloak-js@26.2.3: {} keyv@4.5.4: @@ -15933,6 +17752,10 @@ snapshots: pkg-types: 2.3.0 quansync: 0.2.11 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -15997,6 +17820,9 @@ snapshots: dependencies: yallist: 3.1.1 + lru-cache@7.18.3: + optional: true + luxon@3.5.0: {} magic-regexp@0.10.0: @@ -16033,6 +17859,10 @@ snapshots: make-error@1.3.6: {} + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + markdown-table@3.0.4: {} math-intrinsics@1.1.0: {} @@ -16403,8 +18233,7 @@ snapshots: minimalistic-assert@1.0.1: {} - minimalistic-crypto-utils@1.0.1: - optional: true + minimalistic-crypto-utils@1.0.1: {} minimatch@10.2.4: dependencies: @@ -16445,6 +18274,9 @@ snapshots: mrmime@2.0.1: {} + ms@2.1.2: + optional: true + ms@2.1.3: {} msw@2.12.10(@types/node@22.19.15)(typescript@5.9.3): @@ -16471,7 +18303,6 @@ snapshots: typescript: 5.9.3 transitivePeerDependencies: - '@types/node' - optional: true msw@2.12.10(@types/node@24.12.0)(typescript@5.7.2): dependencies: @@ -16544,6 +18375,8 @@ snapshots: nanoid@5.1.6: {} + napi-postinstall@0.3.4: {} + natural-compare@1.4.0: {} natural-orderby@5.0.0: {} @@ -16559,6 +18392,12 @@ snapshots: neo-async@2.6.2: {} + nest-keycloak-connect@1.10.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(keycloak-connect@25.0.6): + dependencies: + '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + keycloak-connect: 25.0.6 + nestjs-pino@4.6.0(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(pino-http@11.0.0)(pino@10.3.1)(rxjs@7.8.2): dependencies: '@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -16566,6 +18405,9 @@ snapshots: pino-http: 11.0.0 rxjs: 7.8.2 + netmask@2.0.2: + optional: true + nice-try@1.0.5: {} node-abort-controller@3.1.1: {} @@ -16592,6 +18434,8 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + node-int64@0.4.0: {} + node-readfiles@0.2.0: dependencies: es6-promise: 3.3.1 @@ -16635,7 +18479,6 @@ snapshots: npm-run-path@4.0.1: dependencies: path-key: 3.1.1 - optional: true nth-check@2.1.1: dependencies: @@ -16757,8 +18600,7 @@ snapshots: ospath@1.2.2: optional: true - outvariant@1.4.3: - optional: true + outvariant@1.4.3: {} own-keys@1.0.1: dependencies: @@ -16798,10 +18640,18 @@ snapshots: p-debounce@4.0.0: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -16811,6 +18661,28 @@ snapshots: aggregate-error: 3.1.0 optional: true + p-try@2.2.0: {} + + pac-proxy-agent@7.2.0: + dependencies: + '@tootallnate/quickjs-emscripten': 0.23.0 + agent-base: 7.1.4 + debug: 4.4.3 + get-uri: 6.0.5 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + pac-resolver: 7.0.1 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + optional: true + + pac-resolver@7.0.1: + dependencies: + degenerator: 5.0.1 + netmask: 2.0.2 + optional: true + package-json-from-dist@1.0.1: {} package-manager-detector@1.6.0: {} @@ -16851,6 +18723,8 @@ snapshots: path-exists@4.0.0: {} + path-is-absolute@1.0.1: {} + path-key@2.0.1: {} path-key@3.1.1: {} @@ -16867,8 +18741,7 @@ snapshots: lru-cache: 11.2.6 minipass: 7.1.3 - path-to-regexp@6.3.0: - optional: true + path-to-regexp@6.3.0: {} path-to-regexp@8.3.0: {} @@ -16994,6 +18867,12 @@ snapshots: sonic-boom: 4.2.1 thread-stream: 3.1.0 + pirates@4.0.7: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -17066,6 +18945,12 @@ snapshots: pretty-bytes@6.1.1: {} + pretty-format@30.3.0: + dependencies: + '@jest/schemas': 30.0.5 + ansi-styles: 5.2.0 + react-is: 18.3.1 + prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3): dependencies: '@prisma/config': 6.19.2(magicast@0.3.5) @@ -17119,11 +19004,28 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-agent@6.5.0: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 7.18.3 + pac-proxy-agent: 7.2.0 + proxy-from-env: 1.1.0 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + optional: true + proxy-from-env@1.0.0: optional: true proxy-from-env@1.1.0: {} + proxy-from-env@2.0.0: + optional: true + pstree.remy@1.1.8: {} pump@3.0.4: @@ -17135,6 +19037,8 @@ snapshots: pure-rand@6.1.0: {} + pure-rand@7.0.1: {} + qified@0.6.0: dependencies: hookified: 1.15.1 @@ -17186,6 +19090,8 @@ snapshots: re2-wasm@1.0.2: optional: true + react-is@18.3.1: {} + read-pkg@3.0.0: dependencies: load-json-file: 4.0.0 @@ -17302,6 +19208,10 @@ snapshots: reserved-identifiers@1.2.0: {} + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -17328,8 +19238,7 @@ snapshots: ret@0.4.3: {} - rettime@0.10.1: - optional: true + rettime@0.10.1: {} reusify@1.1.0: {} @@ -17621,6 +19530,8 @@ snapshots: sisteransi@1.0.5: {} + slash@3.0.0: {} + slash@5.1.0: {} slice-ansi@3.0.0: @@ -17646,18 +19557,41 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + smart-buffer@4.2.0: + optional: true + smob@1.6.1: {} smtp-address-parser@1.1.0: dependencies: nearley: 2.20.1 + socks-proxy-agent@8.0.5: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + socks: 2.8.7 + transitivePeerDependencies: + - supports-color + optional: true + + socks@2.8.7: + dependencies: + ip-address: 10.1.0 + smart-buffer: 4.2.0 + optional: true + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 source-map-js@1.2.1: {} + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + source-map-support@0.5.21: dependencies: buffer-from: 1.1.2 @@ -17696,6 +19630,8 @@ snapshots: split2@4.2.0: {} + sprintf-js@1.0.3: {} + sshpk@1.18.0: dependencies: asn1: 0.2.6 @@ -17709,6 +19645,10 @@ snapshots: tweetnacl: 0.14.5 optional: true + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stackback@0.0.2: {} statuses@2.0.1: {} @@ -17732,11 +19672,15 @@ snapshots: streamsearch@1.1.0: {} - strict-event-emitter@0.5.1: - optional: true + strict-event-emitter@0.5.1: {} string-argv@0.3.2: {} + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -17830,10 +19774,11 @@ snapshots: strip-bom@3.0.0: {} + strip-bom@4.0.0: {} + strip-comments@2.0.1: {} - strip-final-newline@2.0.0: - optional: true + strip-final-newline@2.0.0: {} strip-indent@4.1.1: {} @@ -18031,6 +19976,14 @@ snapshots: tapable@2.3.0: {} + tcp-port-used@1.0.2: + dependencies: + debug: 4.3.1 + is2: 2.0.9 + transitivePeerDependencies: + - supports-color + optional: true + temp-dir@2.0.0: {} tempy@0.6.0: @@ -18055,6 +20008,12 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.5 + test-exclude@7.0.2: dependencies: '@istanbuljs/schema': 0.1.3 @@ -18096,8 +20055,7 @@ snapshots: tldts-core@6.1.86: {} - tldts-core@7.0.25: - optional: true + tldts-core@7.0.25: {} tldts@6.1.86: dependencies: @@ -18106,11 +20064,12 @@ snapshots: tldts@7.0.25: dependencies: tldts-core: 7.0.25 - optional: true tmp@0.2.5: optional: true + tmpl@1.0.5: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -18145,7 +20104,6 @@ snapshots: tough-cookie@6.0.0: dependencies: tldts: 7.0.25 - optional: true tr46@0.0.3: {} @@ -18241,10 +20199,11 @@ snapshots: dependencies: prelude-ls: 1.2.1 + type-detect@4.0.8: {} + type-fest@0.16.0: {} - type-fest@0.21.3: - optional: true + type-fest@0.21.3: {} type-fest@0.8.1: optional: true @@ -18500,8 +20459,31 @@ snapshots: picomatch: 4.0.3 webpack-virtual-modules: 0.6.2 - until-async@3.0.2: - optional: true + unrs-resolver@1.11.1: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + + until-async@3.0.2: {} untildify@4.0.0: optional: true @@ -18518,6 +20500,8 @@ snapshots: dependencies: punycode: 2.3.1 + url-join@5.0.0: {} + url-template@3.1.1: {} util-deprecate@1.0.2: {} @@ -18528,6 +20512,12 @@ snapshots: v8-compile-cache-lib@3.0.1: {} + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -18807,6 +20797,10 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + watchpack@2.5.1: dependencies: glob-to-regexp: 0.4.1 @@ -19085,6 +21079,11 @@ snapshots: wrappy@1.0.2: {} + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + write-file-atomic@7.0.1: dependencies: signal-exit: 4.1.0 From 6f7f57aad0055d792fa5699ff67310f226fc9a4b Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 12 Mar 2026 12:11:07 +0100 Subject: [PATCH 3/4] refactor(argocd): migrate ArgoCD to NestJS Signed-off-by: William Phetsinorath --- apps/server-nestjs/package.json | 2 + .../configuration/configuration.service.ts | 50 +- .../argocd/argocd-controller.service.spec.ts | 194 +++++++ .../argocd/argocd-controller.service.ts | 148 +++++ .../argocd/argocd-datastore.service.ts | 63 ++ .../src/modules/argocd/argocd.module.ts | 14 + .../src/modules/argocd/argocd.utils.ts | 236 ++++++++ .../src/modules/gitlab/files/.gitlab-ci.yml | 22 + .../src/modules/gitlab/files/mirror.sh | 83 +++ .../modules/gitlab/gitlab-client.service.ts | 15 + .../gitlab/gitlab-controller.service.spec.ts | 539 ++++++++++++++++++ .../gitlab/gitlab-controller.service.ts | 357 ++++++++++++ .../gitlab/gitlab-datastore.service.spec.ts | 36 ++ .../gitlab/gitlab-datastore.service.ts | 103 ++++ .../src/modules/gitlab/gitlab.constant.ts | 10 + .../src/modules/gitlab/gitlab.module.ts | 15 + .../src/modules/gitlab/gitlab.service.spec.ts | 537 +++++++++++++++++ .../src/modules/gitlab/gitlab.service.ts | 314 ++++++++++ .../src/modules/gitlab/gitlab.utils.ts | 144 +++++ .../src/modules/vault/vault-client.service.ts | 100 ++++ .../src/modules/vault/vault.module.ts | 14 + .../src/modules/vault/vault.service.spec.ts | 109 ++++ .../src/modules/vault/vault.service.ts | 127 +++++ .../src/prisma/schema/project.prisma | 1 + apps/server-nestjs/test/gitlab.e2e-spec.ts | 268 +++++++++ pnpm-lock.yaml | 6 + 26 files changed, 3506 insertions(+), 1 deletion(-) create mode 100644 apps/server-nestjs/src/modules/argocd/argocd-controller.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/argocd/argocd-controller.service.ts create mode 100644 apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts create mode 100644 apps/server-nestjs/src/modules/argocd/argocd.module.ts create mode 100644 apps/server-nestjs/src/modules/argocd/argocd.utils.ts create mode 100644 apps/server-nestjs/src/modules/gitlab/files/.gitlab-ci.yml create mode 100644 apps/server-nestjs/src/modules/gitlab/files/mirror.sh create mode 100644 apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts create mode 100644 apps/server-nestjs/src/modules/gitlab/gitlab-controller.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/gitlab/gitlab-controller.service.ts create mode 100644 apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts create mode 100644 apps/server-nestjs/src/modules/gitlab/gitlab.constant.ts create mode 100644 apps/server-nestjs/src/modules/gitlab/gitlab.module.ts create mode 100644 apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/gitlab/gitlab.service.ts create mode 100644 apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts create mode 100644 apps/server-nestjs/src/modules/vault/vault-client.service.ts create mode 100644 apps/server-nestjs/src/modules/vault/vault.module.ts create mode 100644 apps/server-nestjs/src/modules/vault/vault.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/vault/vault.service.ts create mode 100644 apps/server-nestjs/test/gitlab.e2e-spec.ts diff --git a/apps/server-nestjs/package.json b/apps/server-nestjs/package.json index 0a269e1d4..8eb5b1ccb 100644 --- a/apps/server-nestjs/package.json +++ b/apps/server-nestjs/package.json @@ -46,6 +46,7 @@ "@fastify/swagger": "^8.15.0", "@fastify/swagger-ui": "^4.2.0", "@gitbeaker/core": "^40.6.0", + "@gitbeaker/requester-utils": "^40.6.0", "@gitbeaker/rest": "^40.6.0", "@keycloak/keycloak-admin-client": "^24.0.0", "@kubernetes-models/argo-cd": "^2.7.2", @@ -73,6 +74,7 @@ "dotenv": "^16.6.1", "fastify": "^4.29.1", "fastify-keycloak-adapter": "2.3.2", + "js-yaml": "^4.1.1", "json-2-csv": "^5.5.10", "keycloak-connect": "^25.0.0", "mustache": "^4.2.0", diff --git a/apps/server-nestjs/src/cpin-module/infrastructure/configuration/configuration.service.ts b/apps/server-nestjs/src/cpin-module/infrastructure/configuration/configuration.service.ts index f049b3764..d8ab2f426 100644 --- a/apps/server-nestjs/src/cpin-module/infrastructure/configuration/configuration.service.ts +++ b/apps/server-nestjs/src/cpin-module/infrastructure/configuration/configuration.service.ts @@ -36,10 +36,58 @@ export class ConfigurationService { = process.env.CONTACT_EMAIL ?? 'cloudpinative-relations@interieur.gouv.fr' + // argocd + argoNamespace = process.env.ARGO_NAMESPACE ?? 'argocd' + argocdUrl = process.env.ARGOCD_URL + argocdExtraRepositories = process.env.ARGOCD_EXTRA_REPOSITORIES + + // dso + dsoEnvChartVersion = process.env.DSO_ENV_CHART_VERSION ?? 'dso-env-1.6.0' + dsoNsChartVersion = process.env.DSO_NS_CHART_VERSION ?? 'dso-ns-1.1.5' + // plugins mockPlugins = process.env.MOCK_PLUGINS === 'true' - projectRootDir = process.env.PROJECTS_ROOT_DIR + projectRootPath = process.env.PROJECTS_ROOT_DIR pluginsDir = process.env.PLUGINS_DIR ?? '/plugins' + + // gitlab + gitlabToken = process.env.GITLAB_TOKEN + gitlabUrl = process.env.GITLAB_URL + gitlabInternalUrl = process.env.GITLAB_INTERNAL_URL + ? process.env.GITLAB_INTERNAL_URL + : process.env.GITLAB_URL + + gitlabMirrorTokenExpirationDays = Number(process.env.GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS) ?? 180 + gitlabMirrorTokenRotationThresholdDays = Number(process.env.GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS) ?? 90 + + // vault + vaultToken = process.env.VAULT_TOKEN + vaultUrl = process.env.VAULT_URL + vaultInternalUrl = process.env.VAULT_INTERNAL_URL + ? process.env.VAULT_INTERNAL_URL + : process.env.VAULT_URL + + vaultKvName = process.env.VAULT_KV_NAME ?? 'forge-dso' + + // registry (harbor) + harborUrl = process.env.HARBOR_URL + harborInternalUrl = process.env.HARBOR_INTERNAL_URL ?? process.env.HARBOR_URL + harborAdmin = process.env.HARBOR_ADMIN + harborAdminPassword = process.env.HARBOR_ADMIN_PASSWORD + harborRuleTemplate = process.env.HARBOR_RULE_TEMPLATE + harborRuleCount = process.env.HARBOR_RULE_COUNT + harborRetentionCron = process.env.HARBOR_RETENTION_CRON + + // nexus + nexusUrl = process.env.NEXUS_URL + nexusInternalUrl = process.env.NEXUS_INTERNAL_URL ?? process.env.NEXUS_URL + nexusAdmin = process.env.NEXUS_ADMIN + nexusAdminPassword = process.env.NEXUS_ADMIN_PASSWORD + nexusSecretExposedUrl + = process.env.NEXUS__SECRET_EXPOSE_INTERNAL_URL === 'true' + ? (process.env.NEXUS_INTERNAL_URL ?? process.env.NEXUS_URL) + : process.env.NEXUS_URL + NODE_ENV = process.env.NODE_ENV === 'test' ? 'test' diff --git a/apps/server-nestjs/src/modules/argocd/argocd-controller.service.spec.ts b/apps/server-nestjs/src/modules/argocd/argocd-controller.service.spec.ts new file mode 100644 index 000000000..414e3d634 --- /dev/null +++ b/apps/server-nestjs/src/modules/argocd/argocd-controller.service.spec.ts @@ -0,0 +1,194 @@ +import { Test } from '@nestjs/testing' +import type { TestingModule } from '@nestjs/testing' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { Mocked } from 'vitest' +import { dump } from 'js-yaml' +import { ArgoCDControllerService } from './argocd-controller.service' +import { ArgoCDDatastoreService } from './argocd-datastore.service' +import type { ProjectWithDetails } from './argocd-datastore.service' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { GitlabService } from '../gitlab/gitlab.service' +import { VaultService } from '../vault/vault.service' +import type { ProjectSchema } from '@gitbeaker/core' +import { generateNamespaceName } from '@cpn-console/shared' + +function createArgoCDControllerServiceTestingModule() { + return Test.createTestingModule({ + providers: [ + ArgoCDControllerService, + { + provide: ArgoCDDatastoreService, + useValue: { + getAllProjects: vi.fn(), + } satisfies Partial, + }, + { + provide: ConfigurationService, + useValue: { + argoNamespace: 'argocd', + argocdUrl: 'http://argocd', + argocdExtraRepositories: 'repo3', + dsoEnvChartVersion: 'dso-env-1.6.0', + dsoNsChartVersion: 'dso-ns-1.1.5', + } satisfies Partial, + }, + { + provide: GitlabService, + useValue: { + getOrCreateInfraGroupRepo: vi.fn(), + getProjectGroupPublicUrl: vi.fn(), + getInfraGroupRepoPublicUrl: vi.fn(), + maybeCommitUpdate: vi.fn(), + maybeCommitDelete: vi.fn(), + listFiles: vi.fn(), + } satisfies Partial, + }, + { + provide: VaultService, + useValue: { + readProjectValues: vi.fn(), + } satisfies Partial, + }, + ], + }) +} + +describe('argoCDControllerService', () => { + let service: ArgoCDControllerService + let datastore: Mocked + let gitlab: Mocked + let vault: Mocked + + beforeEach(async () => { + vi.clearAllMocks() + const module: TestingModule = await createArgoCDControllerServiceTestingModule().compile() + service = module.get(ArgoCDControllerService) + datastore = module.get(ArgoCDDatastoreService) + gitlab = module.get(GitlabService) + vault = module.get(VaultService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('reconcile', () => { + it('should sync project environments', async () => { + const mockProject = { + id: '123e4567-e89b-12d3-a456-426614174000', + slug: 'project-1', + name: 'Project 1', + environments: [ + { id: '123e4567-e89b-12d3-a456-426614174001', name: 'dev', clusterId: 'c1', cpu: 1, gpu: 0, memory: 1, autosync: true }, + { id: '123e4567-e89b-12d3-a456-426614174002', name: 'prod', clusterId: 'c1', cpu: 1, gpu: 0, memory: 1, autosync: true }, + ], + clusters: [ + { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } }, + ], + repositories: [ + { + id: 'repo-1', + internalRepoName: 'infra-repo', + url: 'http://gitlab/infra-repo', + isInfra: true, + }, + ], + plugins: [{ pluginName: 'argocd', key: 'extraRepositories', value: 'repo2' }], + } as unknown as ProjectWithDetails + + datastore.getAllProjects.mockResolvedValue([mockProject]) + gitlab.getOrCreateInfraGroupRepo.mockResolvedValue({ id: 100, http_url_to_repo: 'http://gitlab/infra' } as ProjectSchema) + gitlab.getProjectGroupPublicUrl.mockResolvedValue('http://gitlab/group') + gitlab.getInfraGroupRepoPublicUrl.mockResolvedValue('http://gitlab/infra-repo') + gitlab.listFiles.mockResolvedValue([]) + vault.readProjectValues.mockResolvedValue({ data: { secret: 'value' }, error: null } as any) + + const results = await service.reconcile() + + expect(results).toHaveLength(3) // 2 envs + 1 cleanup (1 zone) + + // Verify Gitlab calls + expect(gitlab.maybeCommitUpdate).toHaveBeenCalledTimes(2) + expect(gitlab.maybeCommitUpdate).toHaveBeenCalledWith( + 100, + [ + { + content: dump({ + common: { + 'dso/project': 'Project 1', + 'dso/project.id': '123e4567-e89b-12d3-a456-426614174000', + 'dso/project.slug': 'project-1', + 'dso/environment': 'dev', + 'dso/environment.id': '123e4567-e89b-12d3-a456-426614174001', + }, + argocd: { + cluster: 'in-cluster', + namespace: 'argocd', + project: 'project-1-dev-6293', + envChartVersion: 'dso-env-1.6.0', + nsChartVersion: 'dso-ns-1.1.5', + }, + environment: { + valueFileRepository: 'http://gitlab/infra', + valueFileRevision: 'HEAD', + valueFilePath: 'Project 1/cluster-1/dev/values.yaml', + roGroup: '/project-project-1/console/dev/RO', + rwGroup: '/project-project-1/console/dev/RW', + }, + application: { + quota: { + cpu: 1, + gpu: 0, + memory: '1Gi', + }, + sourceRepositories: [ + 'http://gitlab/group/**', + 'repo3', + 'repo2', + ], + destination: { + namespace: generateNamespaceName(mockProject.id, mockProject.environments[0].id), + name: 'cluster-1', + }, + autosync: true, + vault: { secret: 'value' }, + repositories: [ + { + repoURL: 'http://gitlab/infra-repo', + targetRevision: 'HEAD', + path: '.', + valueFiles: [], + }, + ], + }, + }), + filePath: 'Project 1/cluster-1/dev/values.yaml', + }, + ], + 'ci: :robot_face: Update Project 1/cluster-1/dev/values.yaml', + ) + }) + + it('should handle errors gracefully', async () => { + const mockProject = { + id: '123e4567-e89b-12d3-a456-426614174000', + slug: 'project-1', + name: 'Project 1', + environments: [{ id: '123e4567-e89b-12d3-a456-426614174001', name: 'dev', clusterId: 'c1', cpu: 1, gpu: 0, memory: 1, autosync: true }], + clusters: [ + { id: 'c1', label: 'cluster-1', zone: { slug: 'zone-1' } }, + ], + } as unknown as ProjectWithDetails + + datastore.getAllProjects.mockResolvedValue([mockProject]) + gitlab.getOrCreateInfraGroupRepo.mockRejectedValue(new Error('Sync failed')) + + const results = await service.reconcile() + + // 1 env (fails) + 1 cleanup (fails because getOrCreateInfraProject fails) + expect(results).toHaveLength(2) + const failed = results.filter((r: any) => r.status === 'rejected') + expect(failed).toHaveLength(2) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/argocd/argocd-controller.service.ts b/apps/server-nestjs/src/modules/argocd/argocd-controller.service.ts new file mode 100644 index 000000000..2ebff4a5c --- /dev/null +++ b/apps/server-nestjs/src/modules/argocd/argocd-controller.service.ts @@ -0,0 +1,148 @@ +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { Cron, CronExpression } from '@nestjs/schedule' +import { dump } from 'js-yaml' + +import { ArgoCDDatastoreService } from './argocd-datastore.service' +import type { ProjectWithDetails } from './argocd-datastore.service' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { GitlabService } from '../gitlab/gitlab.service' +import { VaultService } from '../vault/vault.service' +import { + formatEnvironmentValuesFilePath, + formatValues, + getDistinctZones, +} from './argocd.utils' + +@Injectable() +export class ArgoCDControllerService { + private readonly logger = new Logger(ArgoCDControllerService.name) + + constructor( + @Inject(ArgoCDDatastoreService) private readonly argoCDDatastore: ArgoCDDatastoreService, + @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(GitlabService) private readonly gitlab: GitlabService, + @Inject(VaultService) private readonly vault: VaultService, + ) { + this.logger.log('ArgoCDControllerService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails) { + this.logger.log(`Handling project upsert for ${project.slug}`) + return this.reconcile() + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails) { + this.logger.log(`Handling project delete for ${project.slug}`) + return this.reconcile() + } + + @Cron(CronExpression.EVERY_HOUR) + async handleCron() { + this.logger.log('Starting ArgoCD reconciliation') + await this.reconcile() + } + + async reconcile() { + const projects = await this.argoCDDatastore.getAllProjects() + const results: PromiseSettledResult[] = [] + + const projectResults = await Promise.all(projects.map(async (project) => { + const pResults: PromiseSettledResult[] = [] + + const ensureResults = await Promise.allSettled( + project.environments.map(env => this.generateValues(project, env)), + ) + pResults.push(...ensureResults) + + const cleanupResults = await this.cleanupStaleValues(project) + pResults.push(...cleanupResults) + + return pResults + })) + + results.push(...projectResults.flat()) + + results.forEach((result) => { + if (result.status === 'rejected') { + this.logger.error(`Reconciliation failed: ${result.reason}`) + } + }) + + return results + } + + private async cleanupStaleValues(project: ProjectWithDetails) { + const zones = getDistinctZones(project) + return Promise.allSettled(zones.map(async (zoneSlug) => { + const infraProject = await this.gitlab.getOrCreateInfraGroupRepo(zoneSlug) + const existingFiles = await this.gitlab.listFiles(infraProject.id, { + path: `${project.name}/`, + recursive: true, + }) + + const neededFiles = project.environments + .filter((env) => { + const cluster = project.clusters.find(c => c.id === env.clusterId) + return cluster?.zone.slug === zoneSlug + }) + .map((env) => { + const cluster = project.clusters.find(c => c.id === env.clusterId)! + return formatEnvironmentValuesFilePath(project, cluster, env) + }) + + const filesToDelete = existingFiles + .filter((existingFile) => { + return ( + existingFile.name === 'values.yaml' + && !neededFiles.includes(existingFile.path) + ) + }) + .map(existingFile => existingFile.path) + + await this.gitlab.maybeCommitDelete(infraProject.id, filesToDelete) + })) + } + + async generateValues( + project: ProjectWithDetails, + environment: ProjectWithDetails['environments'][number], + ) { + const vaultValuesResult = await this.vault.readProjectValues(project.id) + if (vaultValuesResult.error) { + throw new Error(`Failed to read project values from Vault: ${vaultValuesResult.error.kind}`) + } + const vaultValues = vaultValuesResult.data + const cluster = project.clusters.find(c => c.id === environment.clusterId) + if (!cluster) throw new Error(`Cluster not found for environment ${environment.id}`) + + const infraProject = await this.gitlab.getOrCreateInfraGroupRepo(cluster.zone.slug) + const valueFilePath = formatEnvironmentValuesFilePath(project, cluster, environment) + + const repo = project.repositories.find(r => r.isInfra) + if (!repo) throw new Error(`Infra repository not found for project ${project.id}`) + const repoUrl = await this.gitlab.getInfraGroupRepoPublicUrl(repo.internalRepoName) + + const values = formatValues({ + project, + environment, + cluster, + gitlabPublicGroupUrl: await this.gitlab.getProjectGroupPublicUrl(), + argocdExtraRepositories: this.config.argocdExtraRepositories, + infraProject, + valueFilePath, + repoUrl, + vaultValues, + argoNamespace: this.config.argoNamespace, + envChartVersion: this.config.dsoEnvChartVersion, + nsChartVersion: this.config.dsoNsChartVersion, + }) + + await this.gitlab.maybeCommitUpdate(infraProject.id, [{ + content: dump(values), + filePath: valueFilePath, + }], `ci: :robot_face: Update ${valueFilePath}`) + } +} diff --git a/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts b/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts new file mode 100644 index 000000000..1eccc780e --- /dev/null +++ b/apps/server-nestjs/src/modules/argocd/argocd-datastore.service.ts @@ -0,0 +1,63 @@ +import { Inject, Injectable } from '@nestjs/common' +import type { Prisma } from '@prisma/client' +import { PrismaService } from '@/cpin-module/infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + name: true, + slug: true, + plugins: { + select: { + pluginName: true, + key: true, + value: true, + }, + }, + repositories: { + select: { + id: true, + internalRepoName: true, + isInfra: true, + helmValuesFiles: true, + deployRevision: true, + deployPath: true, + }, + }, + environments: { + select: { + id: true, + name: true, + clusterId: true, + cpu: true, + gpu: true, + memory: true, + autosync: true, + }, + }, + clusters: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class ArgoCDDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + }) + } +} diff --git a/apps/server-nestjs/src/modules/argocd/argocd.module.ts b/apps/server-nestjs/src/modules/argocd/argocd.module.ts new file mode 100644 index 000000000..ee8080467 --- /dev/null +++ b/apps/server-nestjs/src/modules/argocd/argocd.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common' +import { ArgoCDControllerService } from './argocd-controller.service' +import { ArgoCDDatastoreService } from './argocd-datastore.service' +import { ConfigurationModule } from '@/cpin-module/infrastructure/configuration/configuration.module' +import { InfrastructureModule } from '@/cpin-module/infrastructure/infrastructure.module' +import { GitlabModule } from '../gitlab/gitlab.module' +import { VaultModule } from '../vault/vault.module' + +@Module({ + imports: [ConfigurationModule, InfrastructureModule, GitlabModule, VaultModule], + providers: [ArgoCDControllerService, ArgoCDDatastoreService], + exports: [], +}) +export class ArgoCDModule {} diff --git a/apps/server-nestjs/src/modules/argocd/argocd.utils.ts b/apps/server-nestjs/src/modules/argocd/argocd.utils.ts new file mode 100644 index 000000000..fbb480baa --- /dev/null +++ b/apps/server-nestjs/src/modules/argocd/argocd.utils.ts @@ -0,0 +1,236 @@ +import { createHmac } from 'node:crypto' +import { generateNamespaceName, inClusterLabel } from '@cpn-console/shared' +import type { ProjectWithDetails } from './argocd-datastore.service.js' +import z from 'zod' + +export const valuesSchema = z.object({ + common: z.object({ + 'dso/project': z.string(), + 'dso/project.id': z.string(), + 'dso/project.slug': z.string(), + 'dso/environment': z.string(), + 'dso/environment.id': z.string(), + }), + argocd: z.object({ + cluster: z.string(), + namespace: z.string(), + project: z.string(), + envChartVersion: z.string(), + nsChartVersion: z.string(), + }), + environment: z.object({ + valueFileRepository: z.string(), + valueFileRevision: z.string(), + valueFilePath: z.string(), + roGroup: z.string(), + rwGroup: z.string(), + }), + application: z.object({ + quota: z.object({ + cpu: z.number(), + gpu: z.number(), + memory: z.string(), + }), + sourceRepositories: z.array(z.string()), + destination: z.object({ + namespace: z.string(), + name: z.string(), + }), + autosync: z.boolean(), + vault: z.record(z.any()), + repositories: z.array(z.object({ + repoURL: z.string(), + targetRevision: z.string(), + path: z.string(), + valueFiles: z.array(z.string()), + })), + }), +}) + +export function formatReadOnlyGroupName(projectSlug: string, environmentName: string) { + return `/project-${projectSlug}/console/${environmentName}/RO` +} + +export function formatReadWriteGroupName(projectSlug: string, environmentName: string) { + return `/project-${projectSlug}/console/${environmentName}/RW` +} + +export function formatAppProjectName(projectSlug: string, env: string) { + const envHash = createHmac('sha256', '') + .update(env) + .digest('hex') + .slice(0, 4) + return `${projectSlug}-${env}-${envHash}` +} + +export function formatEnvironmentValuesFilePath(project: { name: string }, cluster: { label: string }, env: { name: string }): string { + return `${project.name}/${cluster.label}/${env.name}/values.yaml` +} + +export function getDistinctZones(project: ProjectWithDetails) { + const zones = new Set() + project.clusters.forEach(c => zones.add(c.zone.slug)) + return [...zones] +} + +export function splitExtraRepositories(extraRepositories: string | undefined): string[] { + if (!extraRepositories) return [] + return extraRepositories.split(',').map(r => r.trim()).filter(r => r.length > 0) +} + +export function formatRepositoriesValues( + repositories: ProjectWithDetails['repositories'], + repoUrl: string, + envName: string, +) { + return repositories + .filter(repo => repo.isInfra) + .map((repository) => { + const valueFiles = splitExtraRepositories(repository.helmValuesFiles?.replaceAll('', envName)) + return { + repoURL: repoUrl, + targetRevision: repository.deployRevision || 'HEAD', + path: repository.deployPath || '.', + valueFiles, + } satisfies z.infer[number] + }) +} + +export function formatEnvironmentValues( + infraProject: { http_url_to_repo: string }, + valueFilePath: string, + roGroup: string, + rwGroup: string, +) { + return { + valueFileRepository: infraProject.http_url_to_repo, + valueFileRevision: 'HEAD', + valueFilePath, + roGroup, + rwGroup, + } satisfies z.infer +} + +export interface FormatSourceRepositoriesValuesOptions { + gitlabPublicGroupUrl: string + argocdExtraRepositories?: string + projectPlugins?: ProjectWithDetails['plugins'] +} + +export function formatSourceRepositoriesValues( + { gitlabPublicGroupUrl, argocdExtraRepositories, projectPlugins }: FormatSourceRepositoriesValuesOptions, +): string[] { + let projectExtraRepositories = '' + if (projectPlugins) { + const argocdPlugin = projectPlugins.find(p => p.pluginName === 'argocd' && p.key === 'extraRepositories') + if (argocdPlugin) projectExtraRepositories = argocdPlugin.value + } + + return [ + `${gitlabPublicGroupUrl}/**`, + ...splitExtraRepositories(argocdExtraRepositories), + ...splitExtraRepositories(projectExtraRepositories), + ] +} + +export interface FormatCommonOptions { + project: ProjectWithDetails + environment: ProjectWithDetails['environments'][number] +} + +export function formatCommon({ project, environment }: FormatCommonOptions) { + return { + 'dso/project': project.name, + 'dso/project.id': project.id, + 'dso/project.slug': project.slug, + 'dso/environment': environment.name, + 'dso/environment.id': environment.id, + } satisfies z.infer +} + +export interface FormatArgoCDValuesOptions { + namespace: string + project: string + envChartVersion: string + nsChartVersion: string +} + +export function formatArgoCDValues(options: FormatArgoCDValuesOptions) { + const { namespace, project, envChartVersion, nsChartVersion } = options + return { + cluster: inClusterLabel, + namespace, + project, + envChartVersion, + nsChartVersion, + } satisfies z.infer +} + +export interface FormatValuesOptions { + project: ProjectWithDetails + environment: ProjectWithDetails['environments'][number] + cluster: ProjectWithDetails['clusters'][number] + gitlabPublicGroupUrl: string + argocdExtraRepositories?: string + vaultValues: Record + infraProject: { http_url_to_repo: string } + valueFilePath: string + repoUrl: string + argoNamespace: string + envChartVersion: string + nsChartVersion: string +} + +export function formatValues({ + project, + environment, + cluster, + gitlabPublicGroupUrl, + argocdExtraRepositories, + vaultValues, + infraProject, + valueFilePath, + repoUrl, + argoNamespace, + envChartVersion, + nsChartVersion, +}: FormatValuesOptions) { + return { + common: formatCommon({ project, environment }), + argocd: formatArgoCDValues({ + namespace: argoNamespace, + project: formatAppProjectName(project.slug, environment.name), + envChartVersion, + nsChartVersion, + }), + environment: formatEnvironmentValues( + infraProject, + valueFilePath, + formatReadOnlyGroupName(project.slug, environment.name), + formatReadWriteGroupName(project.slug, environment.name), + ), + application: { + quota: { + cpu: environment.cpu, + gpu: environment.gpu, + memory: `${environment.memory}Gi`, + }, + sourceRepositories: formatSourceRepositoriesValues({ + gitlabPublicGroupUrl, + argocdExtraRepositories, + projectPlugins: project.plugins, + }), + destination: { + namespace: generateNamespaceName(project.id, environment.id), + name: cluster.label, + }, + autosync: environment.autosync, + vault: vaultValues, + repositories: formatRepositoriesValues( + project.repositories, + repoUrl, + environment.name, + ), + }, + } satisfies z.infer +} diff --git a/apps/server-nestjs/src/modules/gitlab/files/.gitlab-ci.yml b/apps/server-nestjs/src/modules/gitlab/files/.gitlab-ci.yml new file mode 100644 index 000000000..ca9be2984 --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/files/.gitlab-ci.yml @@ -0,0 +1,22 @@ +variables: + PROJECT_NAME: + description: Nom du dépôt (dans ce Gitlab) à synchroniser. + GIT_BRANCH_DEPLOY: + description: Nom de la branche à synchroniser. + value: main + SYNC_ALL: + description: Synchroniser toutes les branches. + value: "false" + +include: + - project: $CATALOG_PATH + file: mirror.yml + ref: main + +repo_pull_sync: + extends: .repo_pull_sync + only: + - api + - triggers + - web + - schedules diff --git a/apps/server-nestjs/src/modules/gitlab/files/mirror.sh b/apps/server-nestjs/src/modules/gitlab/files/mirror.sh new file mode 100644 index 000000000..c50c923f8 --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/files/mirror.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +set -e + +# Colorize terminal +red='\\e[0;31m' +no_color='\\033[0m' + +# Console step increment +i=1 + +# Default values +BRANCH_TO_SYNC=main + +print_help() { + TEXT_HELPER="\\nThis script aims to send a synchronization request to DSO.\\nFollowing flags are available: + -a Api url to send the synchronization request + -b Branch which is wanted to be synchronize for the given repository (default '$BRANCH_TO_SYNC') + -g GitLab token to trigger the pipeline on the gitlab mirror project + -i Gitlab mirror project id + -r Gitlab repository name to mirror + -h Print script help\\n" + printf "$TEXT_HELPER" +} + +print_args() { + printf "\\nArguments received: + -a API_URL: $API_URL + -b BRANCH_TO_SYNC: $BRANCH_TO_SYNC + -g GITLAB_TRIGGER_TOKEN length: \${#GITLAB_TRIGGER_TOKEN} + -i GITLAB_MIRROR_PROJECT_ID: $GITLAB_MIRROR_PROJECT_ID + -r REPOSITORY_NAME: $REPOSITORY_NAME\\n" +} + +# Parse options +while getopts :ha:b:g:i:r: flag +do + case "\${flag}" in + a) + API_URL=\${OPTARG};; + b) + BRANCH_TO_SYNC=\${OPTARG};; + g) + GITLAB_TRIGGER_TOKEN=\${OPTARG};; + i) + GITLAB_MIRROR_PROJECT_ID=\${OPTARG};; + r) + REPOSITORY_NAME=\${OPTARG};; + h) + printf "\\nHelp requested.\\n" + print_help + printf "\\nExiting.\\n" + exit 0;; + *) + printf "\\nInvalid argument \${OPTARG} (\${flag}).\\n" + print_help + print_args + exit 1;; + esac +done + +# Test if arguments are missing +if [ -z \${API_URL} ] || [ -z \${BRANCH_TO_SYNC} ] || [ -z \${GITLAB_TRIGGER_TOKEN} ] || [ -z \${GITLAB_MIRROR_PROJECT_ID} ] || [ -z \${REPOSITORY_NAME} ]; then + printf "\\nArgument(s) missing!\\n" + print_help + print_args + exit 2 +fi + +# Print arguments +print_args + +# Send synchronization request +printf "\\n\${red}\${i}.\${no_color} Send request to DSO api.\\n\\n" + +curl \\ + -X POST \\ + --fail \\ + -F token=\${GITLAB_TRIGGER_TOKEN} \\ + -F ref=main \\ + -F variables[GIT_BRANCH_DEPLOY]=\${BRANCH_TO_SYNC} \\ + -F variables[PROJECT_NAME]=\${REPOSITORY_NAME} \\ + "\${API_URL}/api/v4/projects/\${GITLAB_MIRROR_PROJECT_ID}/trigger/pipeline" diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts new file mode 100644 index 000000000..1e5e67d38 --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts @@ -0,0 +1,15 @@ +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { Gitlab } from '@gitbeaker/rest' +import { Injectable, Inject } from '@nestjs/common' + +@Injectable() +export class GitlabClientService extends Gitlab { + constructor( + @Inject(ConfigurationService) readonly config: ConfigurationService, + ) { + super({ + token: config.gitlabToken, + host: config.gitlabInternalUrl, + }) + } +} diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-controller.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-controller.service.spec.ts new file mode 100644 index 000000000..62d7b3031 --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-controller.service.spec.ts @@ -0,0 +1,539 @@ +import { Test } from '@nestjs/testing' +import { ENABLED } from '@cpn-console/shared' +import { GitlabControllerService } from './gitlab-controller.service' +import { GitlabService } from './gitlab.service' +import type { ProjectWithDetails } from './gitlab-datastore.service' +import { GitlabDatastoreService } from './gitlab-datastore.service' +import { VaultService } from '../vault/vault.service' +import type { VaultError, VaultResult } from '../vault/vault-client.service' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { vi, describe, beforeEach, it, expect } from 'vitest' +import type { Mocked } from 'vitest' +import type { AccessTokenExposedSchema, ExpandedUserSchema, GroupSchema, MemberSchema, PipelineTriggerTokenSchema, ProjectSchema, SimpleUserSchema } from '@gitbeaker/core' +import { AccessLevel } from '@gitbeaker/core' +import { faker } from '@faker-js/faker' + +function createGitlabControllerServiceTestingModule() { + return Test.createTestingModule({ + providers: [ + GitlabControllerService, + { + provide: GitlabService, + useValue: { + getOrCreateProjectSubGroup: vi.fn(), + getGroupMembers: vi.fn(), + addGroupMember: vi.fn(), + editGroupMember: vi.fn(), + removeGroupMember: vi.fn(), + getUserByEmail: vi.fn(), + createUser: vi.fn(), + getRepos: vi.fn(), + getProjectToken: vi.fn(), + getInfraGroupRepoPublicUrl: vi.fn(), + maybeCommitUpdate: vi.fn(), + deleteGroup: vi.fn(), + commitMirror: vi.fn(), + getOrCreateMirrorPipelineTriggerToken: vi.fn(), + createProjectToken: vi.fn(), + createMirrorAccessToken: vi.fn(), + upsertProjectGroupRepo: vi.fn(), + upsertProjectMirrorRepo: vi.fn(), + getProjectGroupInternalRepoUrl: vi.fn(), + } satisfies Partial, + }, + { + provide: GitlabDatastoreService, + useValue: { + getAllProjects: vi.fn(), + } satisfies Partial, + }, + { + provide: VaultService, + useValue: { + read: vi.fn(), + write: vi.fn(), + destroy: vi.fn(), + readGitlabMirrorCreds: vi.fn(), + writeGitlabMirrorCreds: vi.fn(), + deleteGitlabMirrorCreds: vi.fn(), + readMirrorCreds: vi.fn(), + writeMirrorCreds: vi.fn(), + writeMirrorTriggerToken: vi.fn(), + } satisfies Partial, + }, + { + provide: ConfigurationService, + useValue: { + projectRootDir: 'forge/console', + projectRootPath: 'forge', + }, + }, + ], + }) +} + +function ok(data: T): VaultResult { + return { data, error: null } +} + +function notFound(method: string, path: string): VaultResult { + const error: VaultError = { kind: 'NotFound', status: 404, method, path } + return { data: null, error } +} + +function makeSimpleUserSchema(overrides: Partial = {}) { + return { + id: 1, + name: 'User', + username: 'user', + state: 'active', + avatar_url: '', + web_url: 'https://gitlab.example/users/user', + created_at: faker.date.past().toISOString(), + ...overrides, + } satisfies SimpleUserSchema +} + +function makeExpandedUserSchema(params: { id: number, email: string, username: string, name: string }): ExpandedUserSchema { + const isoDate = faker.date.past().toISOString() + return { + id: params.id, + name: params.name, + username: params.username, + state: 'active', + avatar_url: '', + web_url: `https://gitlab.example/users/${params.username}`, + created_at: isoDate, + locked: null, + bio: null, + bot: false, + location: null, + public_email: null, + skype: null, + linkedin: null, + twitter: null, + discord: null, + website_url: null, + pronouns: null, + organization: null, + job_title: null, + work_information: null, + followers: null, + following: null, + local_time: null, + is_followed: null, + is_admin: null, + last_sign_in_at: isoDate, + confirmed_at: isoDate, + last_activity_on: isoDate, + email: params.email, + theme_id: 1, + color_scheme_id: 1, + projects_limit: 0, + current_sign_in_at: null, + note: null, + identities: null, + can_create_group: false, + can_create_project: false, + two_factor_enabled: false, + external: false, + private_profile: null, + namespace_id: null, + created_by: null, + } satisfies ExpandedUserSchema +} + +function makeMemberSchema(overrides: Partial = {}) { + return { + id: 1, + username: 'user', + name: 'User', + state: 'active', + avatar_url: '', + web_url: 'https://gitlab.example/users/user', + expires_at: faker.date.future().toISOString(), + access_level: 30, + email: 'user@example.com', + group_saml_identity: { + extern_uid: '', + provider: '', + saml_provider_id: 1, + }, + ...overrides, + } satisfies MemberSchema +} + +function makeGroupSchema(overrides: Partial = {}) { + return { + id: 123, + web_url: 'https://gitlab.example/groups/forge', + name: 'forge', + avatar_url: '', + full_name: 'forge', + full_path: 'forge', + path: 'forge', + description: '', + visibility: 'private', + share_with_group_lock: false, + require_two_factor_authentication: false, + two_factor_grace_period: 0, + project_creation_level: 'maintainer', + subgroup_creation_level: 'maintainer', + lfs_enabled: true, + default_branch_protection: 0, + request_access_enabled: false, + created_at: faker.date.past().toISOString(), + parent_id: 0, + ...overrides, + } satisfies GroupSchema +} + +function makeProjectSchema(overrides: Partial = {}) { + return { + id: 1, + web_url: 'https://gitlab.example/projects/1', + name: 'repo', + path: 'repo', + description: '', + name_with_namespace: 'forge / repo', + path_with_namespace: 'forge/repo', + created_at: faker.date.past().toISOString(), + default_branch: 'main', + topics: [], + ssh_url_to_repo: 'ssh://gitlab.example/forge/repo.git', + http_url_to_repo: 'https://gitlab.example/forge/repo.git', + readme_url: '', + forks_count: 0, + avatar_url: null, + star_count: 0, + last_activity_at: faker.date.future().toISOString(), + namespace: { id: 1, name: 'forge', path: 'forge', kind: 'group', full_path: 'forge', avatar_url: '', web_url: 'https://gitlab.example/groups/forge' }, + description_html: '', + visibility: 'private', + empty_repo: false, + owner: { id: 1, name: 'Owner', created_at: faker.date.past().toISOString() }, + issues_enabled: true, + open_issues_count: 0, + merge_requests_enabled: true, + jobs_enabled: true, + wiki_enabled: true, + snippets_enabled: true, + can_create_merge_request_in: true, + resolve_outdated_diff_discussions: false, + container_registry_access_level: 'enabled', + security_and_compliance_access_level: 'enabled', + container_expiration_policy: { + cadence: '1d', + enabled: false, + keep_n: null, + older_than: null, + name_regex_delete: null, + name_regex_keep: null, + next_run_at: faker.date.future().toISOString(), + }, + updated_at: faker.date.past().toISOString(), + creator_id: 1, + import_url: null, + import_type: null, + import_status: 'none', + import_error: null, + permissions: { + project_access: { access_level: 0, notification_level: 0 }, + group_access: { access_level: 0, notification_level: 0 }, + }, + archived: false, + license_url: '', + license: { key: 'mit', name: 'MIT', nickname: 'MIT', html_url: '', source_url: '' }, + shared_runners_enabled: true, + group_runners_enabled: true, + runners_token: '', + ci_default_git_depth: 0, + ci_forward_deployment_enabled: false, + ci_forward_deployment_rollback_allowed: false, + ci_allow_fork_pipelines_to_run_in_parent_project: false, + ci_separated_caches: false, + ci_restrict_pipeline_cancellation_role: '', + public_jobs: false, + shared_with_groups: null, + repository_storage: '', + only_allow_merge_if_pipeline_succeeds: false, + allow_merge_on_skipped_pipeline: false, + restrict_user_defined_variables: false, + only_allow_merge_if_all_discussions_are_resolved: false, + remove_source_branch_after_merge: false, + printing_merge_requests_link_enabled: false, + request_access_enabled: false, + merge_method: '', + squash_option: '', + auto_devops_enabled: false, + auto_devops_deploy_strategy: '', + mirror: false, + mirror_user_id: 1, + mirror_trigger_builds: false, + only_mirror_protected_branches: false, + mirror_overwrites_diverged_branches: false, + external_authorization_classification_label: '', + packages_enabled: false, + service_desk_enabled: false, + service_desk_address: 'service-desk@example.com', + service_desk_reply_to: 'service-desk@example.com', + autoclose_referenced_issues: false, + suggestion_commit_message: 'Add suggestion commit message', + enforce_auth_checks_on_uploads: false, + merge_commit_template: 'Add suggestion commit message', + squash_commit_template: 'Add suggestion commit message', + issue_branch_template: 'Add suggestion commit message', + marked_for_deletion_on: faker.date.future().toISOString(), + compliance_frameworks: [], + warn_about_potentially_unwanted_characters: false, + container_registry_image_prefix: 'registry.gitlab.example/forge/repo', + _links: { + self: 'https://gitlab.example/projects/1', + issues: 'https://gitlab.example/projects/1/issues', + merge_requests: 'https://gitlab.example/projects/1/merge_requests', + repo_branches: 'https://gitlab.example/projects/1/repository/branches', + labels: 'https://gitlab.example/projects/1/labels', + events: 'https://gitlab.example/projects/1/events', + members: 'https://gitlab.example/projects/1/members', + cluster_agents: 'https://gitlab.example/projects/1/cluster_agents', + }, + ...overrides, + } satisfies ProjectSchema +} + +function makeProjectWithDetails(overrides: Partial = {}) { + return { + id: 'p1', + slug: 'project-1', + name: 'Project 1', + description: 'Test project', + owner: { id: 'o1', email: 'owner@example.com', firstName: 'Owner', lastName: 'User', adminRoleIds: [] }, + plugins: [], + roles: [], + members: [], + repositories: [], + clusters: [], + ...overrides, + } satisfies ProjectWithDetails +} + +function makePipelineTriggerToken(overrides: Partial = {}) { + return { + id: 1, + description: 'mirroring-from-external-repo', + created_at: faker.date.past().toISOString(), + last_used: null, + token: 'trigger-token', + updated_at: faker.date.past().toISOString(), + owner: null, + repoId: 1, + ...overrides, + } satisfies PipelineTriggerTokenSchema +} + +describe('gitlabControllerService', () => { + let service: GitlabControllerService + let gitlab: Mocked + let vault: Mocked + let gitlabDatastore: Mocked + + beforeEach(async () => { + const moduleRef = await createGitlabControllerServiceTestingModule().compile() + service = moduleRef.get(GitlabControllerService) + gitlab = moduleRef.get(GitlabService) + vault = moduleRef.get(VaultService) + gitlabDatastore = moduleRef.get(GitlabDatastoreService) + + vault.writeGitlabMirrorCreds.mockResolvedValue(ok(undefined)) + vault.deleteGitlabMirrorCreds.mockResolvedValue(ok(undefined)) + vault.writeMirrorCreds.mockResolvedValue(ok(undefined)) + vault.writeMirrorTriggerToken.mockResolvedValue(ok(undefined)) + vault.readMirrorCreds.mockResolvedValue(notFound('GET', '/v1/kv/data/forge/project-1/tech/GITLAB_MIRROR')) + vault.readGitlabMirrorCreds.mockResolvedValue(notFound('GET', '/v1/kv/data/forge/project-1/repo-1-mirror')) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('handleUpsert', () => { + it('should reconcile project members and repositories', async () => { + const project = makeProjectWithDetails() + const group = makeGroupSchema({ + id: 123, + full_path: 'forge/console/project-1', + full_name: 'forge/console/project-1', + name: 'project-1', + path: 'project-1', + parent_id: 1, + }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectGroupRepo.mockResolvedValue(makeProjectSchema({ id: 1 })) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getInfraGroupRepoPublicUrl.mockResolvedValue('http://gitlab/repo') + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + gitlab.getUserByEmail.mockResolvedValue(makeSimpleUserSchema({ id: 123, username: 'user' })) + + await service.handleUpsert(project) + + expect(gitlab.getOrCreateProjectSubGroup).toHaveBeenCalledWith(project.slug) + expect(gitlab.getGroupMembers).toHaveBeenCalledWith(group.id) + expect(gitlab.getRepos).toHaveBeenCalledWith(project.slug) + }) + + it('should remove orphan member if purge enabled', async () => { + const project = makeProjectWithDetails({ + plugins: [{ key: 'purge', value: ENABLED }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([makeMemberSchema({ id: 999, username: 'orphan' })]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.removeGroupMember).toHaveBeenCalledWith(group.id, 999) + }) + + it('should not remove managed user (bot) even if purge enabled', async () => { + const project = makeProjectWithDetails({ + plugins: [{ key: 'purge', value: ENABLED }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([makeMemberSchema({ id: 888, username: 'group_123_bot' })]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.removeGroupMember).not.toHaveBeenCalled() + }) + + it('should not remove orphan member if purge disabled', async () => { + const project = makeProjectWithDetails() + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([makeMemberSchema({ id: 999, username: 'orphan' })]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.removeGroupMember).not.toHaveBeenCalled() + }) + + it('should create gitlab user if not exists', async () => { + const project = makeProjectWithDetails({ + members: [{ user: { id: 'u1', email: 'new@example.com', firstName: 'New', lastName: 'User', adminRoleIds: [] }, roleIds: [] }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getUserByEmail.mockResolvedValue(undefined as unknown as SimpleUserSchema) + gitlab.createUser.mockImplementation(async (email, username, name) => { + return makeExpandedUserSchema({ + id: email === 'new@example.com' ? 999 : 998, + email, + username, + name, + }) + }) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.createUser).toHaveBeenCalledWith('new@example.com', 'new.example.com', 'New User') + expect(gitlab.createUser).toHaveBeenCalledWith('owner@example.com', 'owner.example.com', 'Owner User') + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group.id, 999, AccessLevel.GUEST) + expect(gitlab.addGroupMember).toHaveBeenCalledWith(group.id, 998, AccessLevel.OWNER) + }) + + it('should configure repository mirroring if external url is present', async () => { + const project = makeProjectWithDetails({ + repositories: [{ + id: 'r1', + internalRepoName: 'repo-1', + externalRepoUrl: 'https://github.com/org/repo.git', + isPrivate: true, + externalUserName: 'user', + isInfra: false, + }], + }) + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + const gitlabRepo = makeProjectSchema({ id: 101, name: 'repo-1', path: 'repo-1', path_with_namespace: 'forge/console/project-1/repo-1' }) + const accessToken = { + id: 1, + user_id: 1, + scopes: ['read_api'], + name: 'bot', + expires_at: faker.date.future().toISOString(), + active: true, + created_at: faker.date.past().toISOString(), + revoked: false, + access_level: 40, + token: 'mirror-token', + } satisfies AccessTokenExposedSchema + + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockReturnValue((async function* () { yield gitlabRepo })()) + gitlab.getProjectGroupInternalRepoUrl.mockResolvedValue('https://gitlab.internal/group/repo-1.git') + gitlab.createMirrorAccessToken.mockResolvedValue(accessToken) + vault.readMirrorCreds.mockResolvedValue(notFound('GET', '/v1/kv/data/forge/project-1/tech/GITLAB_MIRROR')) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleUpsert(project) + + expect(gitlab.createMirrorAccessToken).toHaveBeenCalledWith('project-1') + expect(gitlab.upsertProjectMirrorRepo).toHaveBeenCalledWith('project-1') + + expect(vault.writeGitlabMirrorCreds).toHaveBeenCalledWith( + 'project-1', + 'repo-1', + expect.objectContaining({ + GIT_INPUT_URL: 'github.com/org/repo.git', + GIT_OUTPUT_USER: 'bot', + GIT_OUTPUT_PASSWORD: 'mirror-token', + }), + ) + expect(vault.writeMirrorCreds).toHaveBeenCalledWith('project-1', { + MIRROR_USER: 'bot', + MIRROR_TOKEN: 'mirror-token', + }) + }) + }) + + describe('handleCron', () => { + it('should reconcile all projects', async () => { + const projects = [makeProjectWithDetails({ id: 'p1', slug: 'project-1' })] + gitlabDatastore.getAllProjects.mockResolvedValue(projects) + + const group = makeGroupSchema({ id: 123, name: 'project-1', path: 'project-1', full_path: 'forge/console/project-1', full_name: 'forge/console/project-1', parent_id: 1 }) + gitlab.getOrCreateProjectSubGroup.mockResolvedValue(group) + gitlab.getGroupMembers.mockResolvedValue([]) + gitlab.getRepos.mockReturnValue((async function* () { })()) + gitlab.upsertProjectMirrorRepo.mockResolvedValue(makeProjectSchema({ id: 1, name: 'mirror', path: 'mirror', path_with_namespace: 'forge/console/project-1/mirror', empty_repo: false })) + gitlab.getOrCreateMirrorPipelineTriggerToken.mockResolvedValue(makePipelineTriggerToken()) + + await service.handleCron() + + expect(gitlabDatastore.getAllProjects).toHaveBeenCalled() + expect(gitlab.getOrCreateProjectSubGroup).toHaveBeenCalledWith('project-1') + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-controller.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-controller.service.ts new file mode 100644 index 000000000..c5ec6d940 --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-controller.service.ts @@ -0,0 +1,357 @@ +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { Cron, CronExpression } from '@nestjs/schedule' +import { AccessLevel } from '@gitbeaker/core' +import type { MemberSchema, ProjectSchema } from '@gitbeaker/core' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { GitlabDatastoreService } from './gitlab-datastore.service' +import type { ProjectWithDetails } from './gitlab-datastore.service' +import { GitlabService } from './gitlab.service' +import { VaultService } from '../vault/vault.service' +import { INFRA_APPS_REPO_NAME } from './gitlab.constant' +import { daysAgoFromNow, generateAccessLevelMapping, generateUsername, getAll, getPluginConfig } from './gitlab.utils' +import type { VaultSecret } from '../vault/vault-client.service' +import { specificallyEnabled } from '@cpn-console/hooks' +import { trace } from '@opentelemetry/api' + +const ownedUserRegex = /group_\d+_bot/u +const tracer = trace.getTracer('gitlab-controller') + +@Injectable() +export class GitlabControllerService { + private readonly logger = new Logger(GitlabControllerService.name) + + constructor( + @Inject(GitlabDatastoreService) private readonly gitlabDatastore: GitlabDatastoreService, + @Inject(GitlabService) private readonly gitlab: GitlabService, + @Inject(VaultService) private readonly vault: VaultService, + @Inject(ConfigurationService) private readonly config: ConfigurationService, + ) { + this.logger.log('GitlabControllerService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails) { + return tracer.startActiveSpan('handleUpsert', async (span) => { + try { + span.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project upsert for ${project.slug}`) + await this.ensureProjectGroup(project) + span.end() + } catch (error) { + if (error instanceof Error) span.recordException(error) + span.end() + throw error + } + }) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails) { + return tracer.startActiveSpan('handleDelete', async (span) => { + try { + span.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project delete for ${project.slug}`) + await this.ensureProjectGroup(project) + span.end() + } catch (error) { + if (error instanceof Error) span.recordException(error) + span.end() + throw error + } + }) + } + + @Cron(CronExpression.EVERY_HOUR) + async handleCron() { + return tracer.startActiveSpan('handleCron', async (span) => { + try { + this.logger.log('Starting Gitlab reconciliation') + const projects = await this.gitlabDatastore.getAllProjects() + span.setAttribute('projects.count', projects.length) + await this.ensureProjectGroups(projects) + span.end() + } catch (error) { + if (error instanceof Error) span.recordException(error) + span.end() + throw error + } + }) + } + + private async ensureProjectGroups(projects: ProjectWithDetails[]) { + tracer.startActiveSpan('ensureProjectGroups', async (span) => { + try { + span.setAttribute('projects.count', projects.length) + await Promise.all(projects.map(p => this.ensureProjectGroup(p))) + span.end() + } catch (error) { + if (error instanceof Error) span.recordException(error) + span.end() + throw error + } + }) + } + + private async ensureProjectGroup(project: ProjectWithDetails) { + return tracer.startActiveSpan('ensureProject', async (span) => { + try { + const group = await this.gitlab.getOrCreateProjectSubGroup(project.slug) + const members = await this.gitlab.getGroupMembers(group.id) + span.setAttribute('project.slug', project.slug) + await this.ensureProjectGroupMembers(project, group.id, members) + await this.ensureProjectRepos(project) + await this.ensureSystemRepos(project) + span.end() + } catch (error) { + if (error instanceof Error) span.recordException(error) + span.end() + this.logger.error(`Failed to reconcile project ${project.slug}: ${error}`) + throw error + } + }) + } + + private async ensureProjectGroupMembers( + project: ProjectWithDetails, + groupId: number, + members: MemberSchema[], + ) { + await this.addMissingMembers(project, groupId, members) + await this.addMissingOwnerMember(project, groupId, members) + await this.purgeOrphanMembers(project, groupId, members) + } + + private async addMissingMembers( + project: ProjectWithDetails, + groupId: number, + members: MemberSchema[], + ) { + const membersById = new Map(members.map(m => [m.id, m])) + const accessLevelByUserId = generateAccessLevelMapping(project) + + await Promise.all(project.members.map(async ({ user }) => { + const gitlabUser = await this.upsertUser(user) + if (!gitlabUser) return + const accessLevel = accessLevelByUserId.get(user.id) ?? AccessLevel.NO_ACCESS + await this.ensureGroupMemberAccessLevel(groupId, gitlabUser.id, accessLevel, membersById) + })) + } + + private async upsertUser(user: ProjectWithDetails['members'][number]['user']) { + let gitlabUser = await this.gitlab.getUserByEmail(user.email) + if (!gitlabUser) { + gitlabUser = await this.gitlab.createUser( + user.email, + generateUsername(user.email), + `${user.firstName} ${user.lastName}`, + ) + } + return gitlabUser + } + + private async ensureGroupMemberAccessLevel( + groupId: number, + gitlabUserId: number, + accessLevel: AccessLevel, + membersById: Map, + ) { + const existingMember = membersById.get(gitlabUserId) + + if (accessLevel === AccessLevel.NO_ACCESS) { + if (existingMember) { + await this.gitlab.removeGroupMember(groupId, gitlabUserId) + } + return + } + + if (!existingMember) { + await this.gitlab.addGroupMember(groupId, gitlabUserId, accessLevel) + return + } + + if (existingMember.access_level !== accessLevel) { + await this.gitlab.editGroupMember(groupId, gitlabUserId, accessLevel) + } + } + + private async addMissingOwnerMember( + project: ProjectWithDetails, + groupId: number, + members: MemberSchema[], + ) { + const gitlabUser = await this.upsertUser(project.owner) + if (!gitlabUser) return + const membersById = new Map(members.map(m => [m.id, m])) + await this.ensureGroupMemberAccessLevel(groupId, gitlabUser.id, AccessLevel.OWNER, membersById) + } + + private async purgeOrphanMembers( + project: ProjectWithDetails, + groupId: number, + members: MemberSchema[], + ) { + const purgeConfig = getPluginConfig(project, 'purge') + const usernames = new Set([ + generateUsername(project.owner.email), + ...project.members.map(m => generateUsername(m.user.email)), + ]) + const emails = new Set([ + project.owner.email.toLowerCase(), + ...project.members.map(m => m.user.email.toLowerCase()), + ]) + + const orphans = members.filter((member) => { + if (this.isOwnedUser(member)) return false + if (usernames.has(member.username)) return false + if (member.email && emails.has(member.email.toLowerCase())) return false + return true + }) + + if (specificallyEnabled(purgeConfig)) { + await Promise.all(orphans.map(async (orphan) => { + await this.gitlab.removeGroupMember(groupId, orphan.id) + this.logger.log(`Removed ${orphan.username} from gitlab group ${groupId}`) + })) + } else { + for (const orphan of orphans) { + this.logger.warn(`User ${orphan.username} is in Gitlab group but not in project (purge disabled)`) + } + } + } + + private isOwnedUser(member: MemberSchema) { + return ownedUserRegex.test(member.username) + } + + private async ensureProjectRepos(project: ProjectWithDetails) { + const gitlabRepositories = await getAll(this.gitlab.getRepos(project.slug)) + for (const repo of project.repositories) { + await this.ensureRepository(project, repo, gitlabRepositories) + + if (repo.externalRepoUrl) { + await this.configureRepositoryMirroring(project, repo) + } else { + const deleted = await this.vault.deleteGitlabMirrorCreds(project.slug, repo.internalRepoName) + if (deleted.error) { + throw new Error(`Failed to delete GitLab mirror creds: ${deleted.error.kind}`) + } + } + } + } + + private async ensureRepository( + project: ProjectWithDetails, + repo: ProjectWithDetails['repositories'][number], + gitlabRepositories: ProjectSchema[], + ) { + let gitlabRepo = gitlabRepositories.find(r => r.name === repo.internalRepoName) + if (!gitlabRepo) { + gitlabRepo = await this.gitlab.upsertProjectGroupRepo( + project.slug, + repo.internalRepoName, + undefined, + ) + } + return gitlabRepo + } + + private async configureRepositoryMirroring( + project: ProjectWithDetails, + repo: ProjectWithDetails['repositories'][number], + ) { + const currentVaultSecretResult = await this.vault.readGitlabMirrorCreds(project.slug, repo.internalRepoName) + if (currentVaultSecretResult.error && currentVaultSecretResult.error.kind !== 'NotFound') { + throw new Error(`Failed to read GitLab mirror creds: ${currentVaultSecretResult.error.kind}`) + } + const currentVaultSecret = currentVaultSecretResult.error?.kind === 'NotFound' + ? null + : currentVaultSecretResult.data + + const internalRepoUrl = await this.gitlab.getProjectGroupInternalRepoUrl(project.slug, repo.internalRepoName) + const externalRepoUrn = repo.externalRepoUrl.split('://')[1] + const internalRepoUrn = internalRepoUrl.split('://')[1] + + const projectMirrorCreds = await this.getOrRotateMirrorCreds(project.slug) + + const mirrorSecretData = { + GIT_INPUT_URL: externalRepoUrn, + GIT_INPUT_USER: repo.isPrivate ? repo.externalUserName : undefined, + GIT_INPUT_PASSWORD: currentVaultSecret?.data?.GIT_INPUT_PASSWORD, // Preserve existing password as it's not in DB + GIT_OUTPUT_URL: internalRepoUrn, + GIT_OUTPUT_USER: projectMirrorCreds.MIRROR_USER, + GIT_OUTPUT_PASSWORD: projectMirrorCreds.MIRROR_TOKEN, + } + + // Write to vault if changed + // Using simplified check + const written = await this.vault.writeGitlabMirrorCreds(project.slug, repo.internalRepoName, mirrorSecretData) + if (written.error) { + throw new Error(`Failed to write GitLab mirror creds: ${written.error.kind}`) + } + } + + private async ensureSystemRepos(project: ProjectWithDetails) { + await Promise.all([ + this.ensureInfraAppsRepo(project.slug), + this.ensureMirrorRepo(project.slug), + ]) + } + + private async ensureInfraAppsRepo(projectSlug: string) { + await this.gitlab.upsertProjectGroupRepo(projectSlug, INFRA_APPS_REPO_NAME, undefined) + } + + private async ensureMirrorRepo(projectSlug: string) { + const mirrorRepo = await this.gitlab.upsertProjectMirrorRepo(projectSlug) + if (mirrorRepo.empty_repo) { + await this.gitlab.commitMirror(mirrorRepo.id) + } + await this.ensureMirrorRepoTriggerToken(projectSlug) + } + + private async ensureMirrorRepoTriggerToken(projectSlug: string) { + const triggerToken = await this.gitlab.getOrCreateMirrorPipelineTriggerToken(projectSlug) + const gitlabSecret = { + PROJECT_SLUG: projectSlug, + GIT_MIRROR_PROJECT_ID: triggerToken.repoId, + GIT_MIRROR_TOKEN: triggerToken.token, + } + const written = await this.vault.writeMirrorTriggerToken(gitlabSecret) + if (written.error) { + throw new Error(`Failed to write mirror trigger token: ${written.error.kind}`) + } + } + + private async getOrRotateMirrorCreds(projectSlug: string) { + const vaultSecretResult = await this.vault.readMirrorCreds(projectSlug) + if (vaultSecretResult.error) { + if (vaultSecretResult.error.kind === 'NotFound') return this.createMirrorAccessToken(projectSlug) + throw new Error(`Failed to read mirror creds: ${vaultSecretResult.error.kind}`) + } + + if (!this.isMirrorCredsExpiring(vaultSecretResult.data)) { + return vaultSecretResult.data.data as { MIRROR_USER: string, MIRROR_TOKEN: string } + } + return this.createMirrorAccessToken(projectSlug) + } + + private async createMirrorAccessToken(projectSlug: string) { + const token = await this.gitlab.createMirrorAccessToken(projectSlug) + const creds = { + MIRROR_USER: token.name, + MIRROR_TOKEN: token.token, + } + const written = await this.vault.writeMirrorCreds(projectSlug, creds) + if (written.error) { + throw new Error(`Failed to write mirror creds: ${written.error.kind}`) + } + return creds + } + + private isMirrorCredsExpiring(vaultSecret: VaultSecret): boolean { + if (!vaultSecret?.metadata?.created_time) return false + const createdTime = new Date(vaultSecret.metadata.created_time) + return daysAgoFromNow(createdTime) > this.config.gitlabMirrorTokenRotationThresholdDays + } +} diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.spec.ts new file mode 100644 index 000000000..50432e5e9 --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.spec.ts @@ -0,0 +1,36 @@ +import { Test } from '@nestjs/testing' +import type { TestingModule } from '@nestjs/testing' +import { GitlabDatastoreService } from './gitlab-datastore.service' +import { PrismaService } from '@/cpin-module/infrastructure/database/prisma.service' +import { mockDeep } from 'vitest-mock-extended' +import { describe, beforeEach, it, expect } from 'vitest' + +const prismaMock = mockDeep() + +function createGitlabDatastoreServiceTestingModule() { + return Test.createTestingModule({ + providers: [ + GitlabDatastoreService, + { provide: PrismaService, useValue: prismaMock }, + ], + }) +} + +describe('gitlabDatastoreService', () => { + let service: GitlabDatastoreService + + beforeEach(async () => { + const module: TestingModule = await createGitlabDatastoreServiceTestingModule().compile() + service = module.get(GitlabDatastoreService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should get user', async () => { + const user = { id: 'user-id' } + prismaMock.user.findUnique.mockResolvedValue(user as any) + await expect(service.getUser('user-id')).resolves.toEqual(user) + }) +}) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts new file mode 100644 index 000000000..8c62b3ec9 --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-datastore.service.ts @@ -0,0 +1,103 @@ +import { Inject, Injectable } from '@nestjs/common' +import type { Prisma } from '@prisma/client' +import { PrismaService } from '@/cpin-module/infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + owner: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + }, + }, + plugins: { + select: { + key: true, + value: true, + }, + }, + roles: { + select: { + id: true, + oidcGroup: true, + }, + }, + members: { + select: { + user: { + select: { + id: true, + email: true, + firstName: true, + lastName: true, + adminRoleIds: true, + }, + }, + roleIds: true, + }, + }, + repositories: { + select: { + id: true, + internalRepoName: true, + isInfra: true, + isPrivate: true, + externalRepoUrl: true, + externalUserName: true, + }, + }, + clusters: { + select: { + id: true, + label: true, + zone: { + select: { + slug: true, + }, + }, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +@Injectable() +export class GitlabDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + where: { + plugins: { + some: { + pluginName: 'gitlab', + }, + }, + }, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getUser(id: string) { + return this.prisma.user.findUnique({ + where: { + id, + }, + }) + } +} diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.constant.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.constant.ts new file mode 100644 index 000000000..7a9763772 --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.constant.ts @@ -0,0 +1,10 @@ +export const INFRA_GROUP_NAME = 'Infra' +export const INFRA_GROUP_PATH = 'infra' +export const INFRA_APPS_REPO_NAME = 'infra-apps' +export const MIRROR_REPO_NAME = 'mirror' + +export const DEFAULT_ADMIN_GROUP_PATH = '/console/admin' +export const DEFAULT_AUDITOR_GROUP_PATH = '/console/readonly' +export const DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX = '/console/admin' +export const DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX = '/console/developer,/console/devops' +export const DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX = '/console/readonly' diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts new file mode 100644 index 000000000..f3f9a3a00 --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common' +import { GitlabService } from './gitlab.service' +import { GitlabControllerService } from './gitlab-controller.service' +import { GitlabDatastoreService } from './gitlab-datastore.service' +import { GitlabClientService } from './gitlab-client.service' +import { ConfigurationModule } from '@/cpin-module/infrastructure/configuration/configuration.module' +import { InfrastructureModule } from '@/cpin-module/infrastructure/infrastructure.module' +import { VaultModule } from '../vault/vault.module' + +@Module({ + imports: [ConfigurationModule, InfrastructureModule, VaultModule], + providers: [GitlabService, GitlabControllerService, GitlabDatastoreService, GitlabClientService], + exports: [GitlabService], +}) +export class GitlabModule {} diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts new file mode 100644 index 000000000..3925b8140 --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts @@ -0,0 +1,537 @@ +import { Test } from '@nestjs/testing' +import type { TestingModule } from '@nestjs/testing' +import { GitlabService } from './gitlab.service' +import { GitlabClientService } from './gitlab-client.service' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import type { MockedFunction } from 'vitest' +import { describe, it, expect, beforeEach } from 'vitest' +import { mockDeep, mockReset } from 'vitest-mock-extended' +import type { ExpandedGroupSchema, MemberSchema, ProjectSchema, RepositoryFileExpandedSchema } from '@gitbeaker/core' +import { GitbeakerRequestError } from '@gitbeaker/requester-utils' + +const gitlabMock = mockDeep() + +function createGitlabServiceTestingModule() { + return Test.createTestingModule({ + providers: [ + GitlabService, + { + provide: GitlabClientService, + useValue: gitlabMock, + }, + { + provide: ConfigurationService, + useValue: { + gitlabUrl: 'https://gitlab.internal', + gitlabToken: 'token', + gitlabInternalUrl: 'https://gitlab.internal', + projectRootPath: 'forge', + } satisfies Partial, + }, + ], + }) +} + +describe('gitlab', () => { + let service: GitlabService + + beforeEach(async () => { + mockReset(gitlabMock) + const module: TestingModule = await createGitlabServiceTestingModule().compile() + service = module.get(GitlabService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + describe('getOrCreateInfraProject', () => { + it('should create infra project if not exists', async () => { + const zoneSlug = 'zone-1' + const rootId = 123 + const infraGroupId = 456 + const projectId = 789 + + // Mock getGroupRootId logic + const gitlabGroupsAllMock = gitlabMock.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + + // Mock Groups.show (root) + gitlabMock.Groups.show.mockResolvedValueOnce({ id: rootId, full_path: 'forge' } as ExpandedGroupSchema) + + // Mock find infra group (not found first) + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + + // Mock create infra group + gitlabMock.Groups.create.mockResolvedValue({ id: infraGroupId, full_path: 'forge/infra' } as ExpandedGroupSchema) + + // Mock find project (not found) + const gitlabProjectsAllMock = gitlabMock.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + + // Mock create project + gitlabMock.Projects.create.mockResolvedValue({ + id: projectId, + path_with_namespace: 'forge/infra/zone-1', + http_url_to_repo: 'http://gitlab/repo.git', + } as ProjectSchema) + + const result = await service.getOrCreateInfraGroupRepo(zoneSlug) + + expect(result).toEqual({ + id: projectId, + http_url_to_repo: 'http://gitlab/repo.git', + path_with_namespace: 'forge/infra/zone-1', + }) + expect(gitlabMock.Groups.create).toHaveBeenCalledWith('infra', 'infra', expect.any(Object)) + expect(gitlabMock.Projects.create).toHaveBeenCalledWith(expect.objectContaining({ + name: zoneSlug, + path: zoneSlug, + namespaceId: infraGroupId, + })) + }) + }) + + describe('commitCreateOrUpdate', () => { + it('should create commit if file not exists', async () => { + const repoId = 1 + const content = 'content' + const filePath = 'file.txt' + + const gitlabRepositoryFilesShowMock = gitlabMock.RepositoryFiles.show as MockedFunction + const notFoundError = new GitbeakerRequestError('Not Found', { cause: { description: '404 File Not Found' } } as any) + gitlabRepositoryFilesShowMock.mockRejectedValue(notFoundError) + + await service.maybeCommitUpdate(repoId, [{ content, filePath }]) + + expect(gitlabMock.Commits.create).toHaveBeenCalledWith( + repoId, + 'main', + expect.any(String), + [{ action: 'create', filePath, content }], + ) + }) + + it('should update commit if content differs', async () => { + const repoId = 1 + const content = 'new content' + const filePath = 'file.txt' + const oldHash = 'oldhash' + + const gitlabRepositoryFilesShowMock = gitlabMock.RepositoryFiles.show as MockedFunction + gitlabRepositoryFilesShowMock.mockResolvedValue({ + content_sha256: oldHash, + } as RepositoryFileExpandedSchema) + + await service.maybeCommitUpdate(repoId, [{ content, filePath }]) + + expect(gitlabMock.Commits.create).toHaveBeenCalledWith( + repoId, + 'main', + expect.any(String), + [{ action: 'update', filePath, content }], + ) + }) + + it('should do nothing if content matches', async () => { + const repoId = 1 + const content = 'content' + const filePath = 'file.txt' + const hash = 'ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73' // sha256 of 'content' + + const gitlabRepositoryFilesShowMock = gitlabMock.RepositoryFiles.show as MockedFunction + gitlabRepositoryFilesShowMock.mockResolvedValue({ + content_sha256: hash, + } as RepositoryFileExpandedSchema) + + await service.maybeCommitUpdate(repoId, [{ content, filePath }]) + + expect(gitlabMock.Commits.create).not.toHaveBeenCalled() + }) + }) + + describe('getOrCreateProjectGroup', () => { + it('should create project group if not exists', async () => { + const projectSlug = 'project-1' + const rootId = 123 + const groupId = 456 + + const gitlabGroupsAllMock = gitlabMock.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabMock.Groups.show.mockResolvedValueOnce({ id: rootId, full_path: 'forge' } as ExpandedGroupSchema) + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + gitlabMock.Groups.create.mockResolvedValue({ id: groupId, name: projectSlug } as ExpandedGroupSchema) + + const result = await service.getOrCreateProjectSubGroup(projectSlug) + + expect(result).toEqual({ id: groupId, name: projectSlug }) + expect(gitlabMock.Groups.create).toHaveBeenCalledWith(projectSlug, projectSlug, expect.objectContaining({ + parentId: rootId, + })) + }) + + it('should return existing group', async () => { + const projectSlug = 'project-1' + const rootId = 123 + const groupId = 456 + + const gitlabGroupsAllMock = gitlabMock.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabMock.Groups.show.mockResolvedValueOnce({ id: rootId, full_path: 'forge' } as ExpandedGroupSchema) + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: projectSlug, parent_id: rootId, full_path: 'forge/project-1' }], + paginationInfo: { next: null }, + }) + + const result = await service.getOrCreateProjectSubGroup(projectSlug) + + expect(result).toEqual({ id: groupId, name: projectSlug, parent_id: rootId, full_path: 'forge/project-1' }) + expect(gitlabMock.Groups.create).not.toHaveBeenCalled() + }) + }) + + describe('repositories', () => { + it('should return internal repo url', async () => { + const projectSlug = 'project-1' + const repoName = 'repo-1' + const rootId = 123 + const groupId = 1 + + const gitlabGroupsAllMock = gitlabMock.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: groupId, full_path: 'forge/project-1' }], + paginationInfo: { next: null }, + }) + + const result = await service.getProjectGroupInternalRepoUrl(projectSlug, repoName) + expect(result).toBe('https://gitlab.internal/forge/project-1/repo-1.git') + }) + + it('should upsert mirror repo', async () => { + const projectSlug = 'project-1' + const repoId = 1 + + const gitlabProjectsAllMock = gitlabMock.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValue({ + data: [{ id: repoId, path_with_namespace: 'forge/project-1/mirror' }], + paginationInfo: { next: null }, + }) + + gitlabMock.Projects.edit.mockResolvedValue({ id: repoId, name: 'mirror' } as ProjectSchema) + + const result = await service.upsertProjectMirrorRepo(projectSlug) + + expect(result).toEqual({ id: repoId, name: 'mirror' }) + expect(gitlabMock.Projects.edit).toHaveBeenCalledWith(repoId, expect.objectContaining({ + name: 'mirror', + path: 'mirror', + })) + }) + + it('should create pipeline trigger token if not exists', async () => { + const projectSlug = 'project-1' + const repoId = 1 + const tokenDescription = 'mirroring-from-external-repo' + + const gitlabProjectsAllMock = gitlabMock.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValue({ + data: [{ id: repoId, path_with_namespace: 'forge/project-1/mirror' }], + paginationInfo: { next: null }, + }) + gitlabMock.Projects.edit.mockResolvedValue({ id: repoId, name: 'mirror' } as ProjectSchema) + + const gitlabPipelineTriggerTokensAllMock = gitlabMock.PipelineTriggerTokens.all as MockedFunction + gitlabPipelineTriggerTokensAllMock.mockResolvedValue({ + data: [], + paginationInfo: { next: null } as any, + }) + + gitlabMock.PipelineTriggerTokens.create.mockResolvedValue({ id: 2, description: tokenDescription } as any) + + const result = await service.getOrCreateMirrorPipelineTriggerToken(projectSlug) + + expect(result).toEqual({ id: 2, description: tokenDescription }) + expect(gitlabMock.PipelineTriggerTokens.create).toHaveBeenCalledWith(repoId, tokenDescription) + }) + }) + + describe('group Members', () => { + it('should get group members', async () => { + const groupId = 1 + const members = [{ id: 1, name: 'user' } as MemberSchema] + const gitlabGroupMembersAllMock = gitlabMock.GroupMembers.all as MockedFunction + gitlabGroupMembersAllMock.mockResolvedValue(members) + + const result = await service.getGroupMembers(groupId) + expect(result).toEqual(members) + expect(gitlabMock.GroupMembers.all).toHaveBeenCalledWith(groupId) + }) + + it('should add group member', async () => { + const groupId = 1 + const userId = 2 + const accessLevel = 30 + gitlabMock.GroupMembers.add.mockResolvedValue({ id: userId } as MemberSchema) + + await service.addGroupMember(groupId, userId, accessLevel) + expect(gitlabMock.GroupMembers.add).toHaveBeenCalledWith(groupId, userId, accessLevel) + }) + + it('should remove group member', async () => { + const groupId = 1 + const userId = 2 + gitlabMock.GroupMembers.remove.mockResolvedValue(undefined) + + await service.removeGroupMember(groupId, userId) + expect(gitlabMock.GroupMembers.remove).toHaveBeenCalledWith(groupId, userId) + }) + }) + + describe('createProjectMirrorAccessToken', () => { + it('should create project access token with correct scopes', async () => { + const projectSlug = 'project-1' + const groupId = 456 + const tokenName = `${projectSlug}-bot` + const token = { id: 1, name: tokenName, token: 'secret-token' } + + // Mock getProjectGroup + const gitlabGroupsAllMock = gitlabMock.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: 123, full_path: 'forge' }], + paginationInfo: { next: null }, + }) // root + gitlabMock.Groups.show.mockResolvedValueOnce({ id: 123, full_path: 'forge' } as ExpandedGroupSchema) + + const gitlabGroupsAllSubgroupsMock = gitlabMock.Groups.allSubgroups as MockedFunction + gitlabGroupsAllSubgroupsMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: projectSlug, parent_id: 123, full_path: 'forge/project-1' }], + paginationInfo: { next: null }, + }) + + gitlabMock.GroupAccessTokens.create.mockResolvedValue(token as any) + + const result = await service.createMirrorAccessToken(projectSlug) + + expect(result).toEqual(token) + expect(gitlabMock.GroupAccessTokens.create).toHaveBeenCalledWith( + groupId, + tokenName, + ['write_repository', 'read_repository', 'read_api'], + expect.any(String), + ) + }) + }) + + describe('getOrCreateProjectGroupRepo', () => { + it('should return existing repo', async () => { + const subGroupPath = 'project-1' + const repoName = 'repo-1' + const fullPath = `${subGroupPath}/${repoName}` + const projectId = 789 + + const gitlabProjectsAllMock = gitlabMock.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValueOnce({ + data: [{ id: projectId, path_with_namespace: `forge/${fullPath}` }], + paginationInfo: { next: null }, + }) + + const result = await service.getOrCreateProjectGroupRepo(fullPath) + + expect(result).toEqual(expect.objectContaining({ id: projectId })) + }) + + it('should create repo if not exists', async () => { + const subGroupPath = 'project-1' + const repoName = 'repo-1' + const fullPath = `${subGroupPath}/${repoName}` + const projectId = 789 + const groupId = 456 + const rootId = 123 + + // Mock repo search (not found) + const gitlabProjectsAllMock = gitlabMock.Projects.all as MockedFunction + gitlabProjectsAllMock.mockResolvedValueOnce({ + data: [], + paginationInfo: { next: null }, + }) + + // Mock parent group retrieval (recursive) + const gitlabGroupsAllMock = gitlabMock.Groups.all as MockedFunction + + // 1. Find root 'forge' + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: rootId, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + + // 2. Find subgroup 'forge/project-1' + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: subGroupPath, parent_id: rootId, full_path: `forge/${subGroupPath}` }], + paginationInfo: { next: null }, + }) + + // Mock repo creation + gitlabMock.Projects.create.mockResolvedValue({ id: projectId, name: repoName } as ProjectSchema) + + const result = await service.getOrCreateProjectGroupRepo(fullPath) + + expect(result).toEqual(expect.objectContaining({ id: projectId })) + expect(gitlabMock.Projects.create).toHaveBeenCalledWith(expect.objectContaining({ + name: repoName, + path: repoName, + namespaceId: groupId, + })) + }) + }) + + describe('getFile', () => { + it('should return file content', async () => { + const repoId = 1 + const filePath = 'file.txt' + const ref = 'main' + const file = { content: 'content' } + + gitlabMock.RepositoryFiles.show.mockResolvedValue(file as any) + + const result = await service.getFile(repoId, filePath, ref) + expect(result).toEqual(file) + }) + + it('should return undefined on 404', async () => { + const repoId = 1 + const filePath = 'file.txt' + const ref = 'main' + const error = new GitbeakerRequestError('Not Found', { cause: { description: '404 File Not Found' } } as any) + + gitlabMock.RepositoryFiles.show.mockRejectedValue(error) + + const result = await service.getFile(repoId, filePath, ref) + expect(result).toBeUndefined() + }) + + it('should throw on other errors', async () => { + const repoId = 1 + const filePath = 'file.txt' + const ref = 'main' + const error = new Error('Some other error') + + gitlabMock.RepositoryFiles.show.mockRejectedValue(error) + + await expect(service.getFile(repoId, filePath, ref)).rejects.toThrow(error) + }) + }) + + describe('listFiles', () => { + it('should return files', async () => { + const repoId = 1 + const files = [{ path: 'file.txt' }] + + gitlabMock.Repositories.allRepositoryTrees.mockResolvedValue(files as any) + + const result = await service.listFiles(repoId) + expect(result).toEqual(files) + }) + + it('should return empty array on 404', async () => { + const repoId = 1 + const error = new GitbeakerRequestError('Not Found', { cause: { description: '404 Tree Not Found' } } as any) + + gitlabMock.Repositories.allRepositoryTrees.mockRejectedValue(error) + + const result = await service.listFiles(repoId) + expect(result).toEqual([]) + }) + }) + + describe('getProjectToken', () => { + it('should return specific token', async () => { + const projectSlug = 'project-1' + const groupId = 456 + const tokenName = `${projectSlug}-bot` + const token = { id: 1, name: tokenName } + + // Mock getProjectGroup + const gitlabGroupsAllMock = gitlabMock.Groups.all as MockedFunction + gitlabGroupsAllMock.mockResolvedValueOnce({ + data: [{ id: 123, full_path: 'forge' }], + paginationInfo: { next: null }, + }) + gitlabMock.Groups.show.mockResolvedValueOnce({ id: 123, full_path: 'forge' } as ExpandedGroupSchema) + const gitlabGroupsAllSubgroupsMock = gitlabMock.Groups.allSubgroups as MockedFunction + gitlabGroupsAllSubgroupsMock.mockResolvedValueOnce({ + data: [{ id: groupId, name: projectSlug, parent_id: 123, full_path: `forge/${projectSlug}` }], + paginationInfo: { next: null }, + }) + + const gitlabGroupAccessTokensAllMock = gitlabMock.GroupAccessTokens.all as MockedFunction + gitlabGroupAccessTokensAllMock.mockResolvedValue({ + data: [token] as any, + paginationInfo: { next: null } as any, + }) + + const result = await service.getProjectToken(projectSlug) + expect(result).toEqual(token) + }) + }) + + describe('createUser', () => { + it('should create user', async () => { + const email = 'user@example.com' + const username = 'user' + const name = 'User Name' + const user = { id: 1, username } + + gitlabMock.Users.create.mockResolvedValue(user as any) + + const result = await service.createUser(email, username, name) + + expect(result).toEqual(user) + expect(gitlabMock.Users.create).toHaveBeenCalledWith(expect.objectContaining({ + email, + username, + name, + skipConfirmation: true, + })) + }) + }) + + describe('commitMirror', () => { + it('should create mirror commit', async () => { + const repoId = 1 + + await service.commitMirror(repoId) + + expect(gitlabMock.Commits.create).toHaveBeenCalledWith( + repoId, + 'main', + expect.any(String), + expect.arrayContaining([ + expect.objectContaining({ filePath: '.gitlab-ci.yml', action: 'create' }), + expect.objectContaining({ filePath: 'mirror.sh', action: 'create' }), + ]), + ) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts new file mode 100644 index 000000000..59870e26d --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts @@ -0,0 +1,314 @@ +import { Inject, Injectable, Logger } from '@nestjs/common' +import type { AccessTokenScopes, CommitAction, GroupSchema, PipelineTriggerTokenSchema } from '@gitbeaker/core' +import { GitbeakerRequestError } from '@gitbeaker/requester-utils' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { readGitlabCIConfigContent, readMirrorScriptContent, find, offsetPaginate, hasFileContentChanged } from './gitlab.utils' +import { GitlabClientService } from './gitlab-client.service' +import { INFRA_GROUP_PATH, MIRROR_REPO_NAME } from './gitlab.constant' +import { join } from 'node:path' + +@Injectable() +export class GitlabService { + private readonly logger = new Logger(GitlabService.name) + + constructor( + @Inject(GitlabClientService) private readonly client: GitlabClientService, + @Inject(ConfigurationService) private readonly config: ConfigurationService, + ) { + } + + async getGroupByPath(path: string) { + return find( + offsetPaginate(opts => this.client.Groups.all({ search: path, ...opts })), + g => g.full_path === path, + ) + } + + async createGroup(path: string) { + return this.client.Groups.create(path, path) + } + + async createSubGroup(parentGroup: GroupSchema, name: string) { + return this.client.Groups.create(name, name, { parentId: parentGroup.id }) + } + + async getOrCreateGroup(path: string) { + const parts = path.split('/') + const rootGroupPath = parts.shift() + if (!rootGroupPath) throw new Error('Invalid projects root dir') + + // Find or create root + let parentGroup = await this.getGroupByPath(rootGroupPath) ?? await this.createGroup(rootGroupPath) + + // Recursively create subgroups + for (const part of parts) { + const fullPath = `${parentGroup.full_path}/${part}` + parentGroup = await this.getGroupByPath(fullPath) ?? await this.createSubGroup(parentGroup, part) + } + + return parentGroup + } + + async getOrCreateProjectGroup() { + if (!this.config.projectRootPath) throw new Error('projectRootPath not configured') + return this.getOrCreateGroup(this.config.projectRootPath) + } + + async getOrCreateProjectSubGroup(subGroupPath: string) { + if (!this.config.projectRootPath) throw new Error('projectRootPath not configured') + return this.getOrCreateGroup(`${this.config.projectRootPath}/${subGroupPath}`) + } + + async getProjectGroupPublicUrl(): Promise { + const projectGroup = await this.getOrCreateProjectGroup() + return `${this.config.gitlabUrl}/${projectGroup.full_path}` + } + + async getInfraGroupRepoPublicUrl(repoName: string): Promise { + const projectGroup = await this.getOrCreateProjectGroup() + return `${this.config.gitlabUrl}/${projectGroup.full_path}/${INFRA_GROUP_PATH}/${repoName}.git` + } + + async getProjectGroupInternalRepoUrl(subGroupPath: string, repoName: string): Promise { + const projectGroup = await this.getOrCreateProjectSubGroup(subGroupPath) + return `${this.config.gitlabInternalUrl}/${projectGroup.full_path}/${repoName}.git` + } + + async getOrCreateProjectGroupRepo(subGroupPath: string) { + if (!this.config.projectRootPath) throw new Error('projectRootPath not configured') + const fullPath = `${this.config.projectRootPath}/${subGroupPath}` + try { + const existingRepo = await this.client.Projects.show(fullPath) + if (existingRepo) return existingRepo + } catch (error) { + if (!(error instanceof GitbeakerRequestError) || !error.cause?.description?.includes('404')) { + throw error + } + } + const repo = await find( + offsetPaginate(opts => this.client.Projects.all({ + search: fullPath, + ...opts, + })), + p => p.path_with_namespace === fullPath, + ) + if (repo) return repo + const parts = subGroupPath.split('/') + const repoName = parts.pop() + if (!repoName) throw new Error('Invalid repo path') + const parentGroup = await this.getOrCreateProjectSubGroup(parts.join('/')) + try { + return await this.client.Projects.create({ + name: repoName, + path: repoName, + namespaceId: parentGroup.id, + }) + } catch (error) { + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('has already been taken')) { + return this.client.Projects.show(fullPath) + } + throw error + } + } + + async getOrCreateInfraGroupRepo(path: string) { + return this.getOrCreateProjectGroupRepo(join(INFRA_GROUP_PATH, path)) + } + + async getFile(repoId: number, filePath: string, ref: string = 'main') { + try { + return await this.client.RepositoryFiles.show(repoId, filePath, ref) + } catch (error) { + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('Not Found')) { + this.logger.debug(`File not found: ${filePath}`) + } else { + throw error + } + } + } + + async maybeCommitUpdate( + repoId: number, + files: { content: string, filePath: string }[], + message: string = 'ci: :robot_face: Update file content', + ref: string = 'main', + ): Promise { + const promises = await Promise.all(files.map(async ({ content, filePath }) => + this.generateCreateOrUpdateAction(repoId, ref, filePath, content), + )) + const actions = promises.filter(action => !!action) + if (actions.length === 0) { + this.logger.debug('No files to update') + return + } + await this.client.Commits.create(repoId, ref, message, actions) + } + + async generateCreateOrUpdateAction(repoId: number, ref, filePath, content: string) { + const file = await this.getFile(repoId, filePath, ref) + if (file && !hasFileContentChanged(file, content)) { + this.logger.debug(`File content is up to date, no need to commit: ${filePath}`) + return null + } + return { + action: file ? 'update' : 'create', + filePath, + content, + } satisfies CommitAction + } + + async maybeCommitDelete(repoId: number, paths: string[], ref: string = 'main'): Promise { + const actions = paths.map(path => ({ + action: 'delete', + filePath: path, + } satisfies CommitAction)) + if (actions.length === 0) { + this.logger.debug('No files to delete') + return + } + await this.client.Commits.create(repoId, ref, 'ci: :robot_face: Delete files', actions) + } + + async listFiles(repoId: number, options: { path?: string, recursive?: boolean, ref?: string } = {}) { + try { + return await this.client.Repositories.allRepositoryTrees(repoId, { + path: options.path ?? '/', + recursive: options.recursive ?? false, + ref: options.ref ?? 'main', + }) + } catch (error) { + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('Not Found')) { + return [] + } + if (error instanceof GitbeakerRequestError && error.cause?.description?.includes('404 Tree Not Found')) { + return [] + } + throw error + } + } + + async getProjectGroup(projectSlug: string): Promise { + const parentGroup = await this.getOrCreateProjectGroup() + return find( + offsetPaginate(opts => this.client.Groups.allSubgroups(parentGroup.id, opts)), + g => g.name === projectSlug, + ) + } + + async deleteGroup(groupId: number): Promise { + await this.client.Groups.remove(groupId) + } + + // --- Members --- + + async getGroupMembers(groupId: number) { + return this.client.GroupMembers.all(groupId) + } + + async addGroupMember(groupId: number, userId: number, accessLevel: number) { + return this.client.GroupMembers.add(groupId, userId, accessLevel) + } + + async editGroupMember(groupId: number, userId: number, accessLevel: number) { + return this.client.GroupMembers.edit(groupId, userId, accessLevel) + } + + async removeGroupMember(groupId: number, userId: number) { + return this.client.GroupMembers.remove(groupId, userId) + } + + async getUserByEmail(email: string) { + const users = await this.client.Users.all({ search: email }) + if (users.length === 0) return null + return users[0] + } + + async createUser(email: string, username: string, name: string) { + // Note: This requires admin token + return this.client.Users.create({ + email, + username, + name, + password: Math.random().toString(36).slice(-8) + Math.random().toString(36).slice(-8), // Dummy password + skipConfirmation: true, + }) + } + + async* getRepos(projectSlug: string) { + const group = await this.getOrCreateProjectSubGroup(projectSlug) + const repos = offsetPaginate(opts => this.client.Groups.allProjects(group.id, { simple: false, ...opts })) + for await (const repo of repos) { + yield repo + } + } + + async upsertProjectGroupRepo(projectSlug: string, repoName: string, description?: string) { + const repo = await this.getOrCreateProjectGroupRepo(`${projectSlug}/${repoName}`) + return this.client.Projects.edit(repo.id, { + name: repoName, + path: repoName, + description, + }) + } + + async commitMirror(repoId: number) { + const actions: CommitAction[] = [ + { + action: 'create', + filePath: '.gitlab-ci.yml', + content: await readGitlabCIConfigContent(), + execute_filemode: false, + }, + { + action: 'create', + filePath: 'mirror.sh', + content: await readMirrorScriptContent(), + execute_filemode: true, + }, + ] + + await this.client.Commits.create( + repoId, + 'main', + 'ci: :construction_worker: first mirror', + actions, + ) + } + + async upsertProjectMirrorRepo(projectSlug: string) { + return this.upsertProjectGroupRepo(projectSlug, MIRROR_REPO_NAME, undefined) + } + + async getProjectToken(projectSlug: string) { + const group = await this.getProjectGroup(projectSlug) + if (!group) throw new Error('Unable to retrieve gitlab project group') + return find( + offsetPaginate(opts => this.client.GroupAccessTokens.all(group.id, opts)), + token => token.name === `${projectSlug}-bot`, + ) + } + + async createProjectToken(projectSlug: string, tokenName: string, scopes: AccessTokenScopes[]) { + const group = await this.getProjectGroup(projectSlug) + if (!group) throw new Error('Unable to retrieve gitlab project group') + const expirationDays = Number(this.config.gitlabMirrorTokenExpirationDays) + const effectiveExpirationDays = Number.isFinite(expirationDays) && expirationDays > 0 ? expirationDays : 30 + const expiryDate = new Date(Date.now() + effectiveExpirationDays * 24 * 60 * 60 * 1000) + return this.client.GroupAccessTokens.create(group.id, tokenName, scopes, expiryDate.toISOString().slice(0, 10)) + } + + async createMirrorAccessToken(projectSlug: string) { + const tokenName = `${projectSlug}-bot` + return this.createProjectToken(projectSlug, tokenName, ['write_repository', 'read_repository', 'read_api']) + } + + async getOrCreateMirrorPipelineTriggerToken(projectSlug: string): Promise { + const tokenDescription = 'mirroring-from-external-repo' + const mirrorRepo = await this.upsertProjectMirrorRepo(projectSlug) + const currentTriggerToken = await find( + offsetPaginate(opts => this.client.PipelineTriggerTokens.all(mirrorRepo.id, opts)), + token => token.description === tokenDescription, + ) + return currentTriggerToken ?? await this.client.PipelineTriggerTokens.create(mirrorRepo.id, tokenDescription) + } +} diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts new file mode 100644 index 000000000..0cb114595 --- /dev/null +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts @@ -0,0 +1,144 @@ +import { AccessLevel } from '@gitbeaker/core' +import type { PaginationRequestOptions, BaseRequestOptions, OffsetPagination, RepositoryFileExpandedSchema } from '@gitbeaker/core' +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import type { ProjectWithDetails } from './gitlab-datastore.service.js' +import { DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX } from './gitlab.constant.js' + +export function generateUsername(email: string) { + return email.replace('@', '.') +} + +export function getPluginConfig(project: ProjectWithDetails, key: string) { + return project.plugins?.find(p => p.key === key)?.value +} + +export function getGroupPathSuffixes(project: ProjectWithDetails, key: string) { + const value = getPluginConfig(project, key) + if (!value) return null + return value.split(',').map(path => `/${project.slug}${path}`) +} + +export function getProjectMaintainerGroupPaths(project: ProjectWithDetails) { + return getGroupPathSuffixes(project, 'projectMaintainerGroupPathSuffix') ?? DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX.split(',') +} + +export function getProjectDeveloperGroupPaths(project: ProjectWithDetails) { + return getGroupPathSuffixes(project, 'projectDeveloperGroupPathSuffix') ?? DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX.split(',') +} + +export function getProjectReporterGroupPaths(project: ProjectWithDetails) { + return getGroupPathSuffixes(project, 'projectReporterGroupPathSuffix') ?? DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX.split(',') +} + +export function getGroupAccessLevelFromProjectRole(project: ProjectWithDetails, user: ProjectWithDetails['members'][number]['user']) { + const projectReporterGroupPathSuffixes = getProjectReporterGroupPaths(project) + const projectDeveloperGroupPathSuffixes = getProjectDeveloperGroupPaths(project) + const projectMaintainerGroupPathSuffixes = getProjectMaintainerGroupPaths(project) + + const getAccessLevel = (role: { oidcGroup: string | null }): number | null => { + if (!role.oidcGroup) return null + if (projectReporterGroupPathSuffixes.includes(role.oidcGroup)) return AccessLevel.REPORTER + if (projectDeveloperGroupPathSuffixes.includes(role.oidcGroup)) return AccessLevel.DEVELOPER + if (projectMaintainerGroupPathSuffixes.includes(role.oidcGroup)) return AccessLevel.MAINTAINER + return null + } + + const membership = project.members.find(member => member.user.id === user.id) + if (!membership) return null + + const rolesById = new Map(project.roles.map(role => [role.id, role])) + + const highestMappedAccessLevel = membership.roleIds.reduce((highestAccessLevel, roleId) => { + const role = rolesById.get(roleId) + if (!role) return highestAccessLevel + const level = getAccessLevel(role) + if (level && level > (highestAccessLevel ?? 0)) return level + return highestAccessLevel + }, null) + + return highestMappedAccessLevel +} + +export function getGroupAccessLevel(project: ProjectWithDetails, user: ProjectWithDetails['members'][number]['user']): number | null { + if (project.owner.id === user.id) return AccessLevel.OWNER + return getGroupAccessLevelFromProjectRole(project, user) +} + +export function generateAccessLevelMapping(project: ProjectWithDetails) { + const projectReporterGroupPathSuffixes = getProjectReporterGroupPaths(project) + const projectDeveloperGroupPathSuffixes = getProjectDeveloperGroupPaths(project) + const projectMaintainerGroupPathSuffixes = getProjectMaintainerGroupPaths(project) + + const getAccessLevelFromOidcGroup = (oidcGroup: string | null): number | null => { + if (!oidcGroup) return null + if (projectReporterGroupPathSuffixes.includes(oidcGroup)) return AccessLevel.REPORTER + if (projectDeveloperGroupPathSuffixes.includes(oidcGroup)) return AccessLevel.DEVELOPER + if (projectMaintainerGroupPathSuffixes.includes(oidcGroup)) return AccessLevel.MAINTAINER + return null + } + + const roleAccessLevelById = new Map( + project.roles.map(role => [role.id, getAccessLevelFromOidcGroup(role.oidcGroup)] as const), + ) + + return new Map(project.members.map((membership) => { + let highest = AccessLevel.GUEST + for (const roleId of membership.roleIds) { + const level = roleAccessLevelById.get(roleId) + if (level && level > highest) highest = level + } + return [membership.user.id, highest] as const + })) +} + +export function digestContent(content: string) { + return createHash('sha256').update(content).digest('hex') +} + +export function hasFileContentChanged(file: RepositoryFileExpandedSchema, content: string) { + return file?.content_sha256 !== digestContent(content) +} + +export function readGitlabCIConfigContent() { + return readFile(join(__dirname, './files/.gitlab-ci.yml'), 'utf-8') +} + +export async function readMirrorScriptContent() { + return await readFile(join(__dirname, './files/mirror.sh'), 'utf-8') +} + +export async function getAll( + iterable: AsyncIterable, +): Promise { + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + } + return items +} + +export async function find(generator: AsyncGenerator, predicate: (item: T) => boolean): Promise { + for await (const item of generator) { + if (predicate(item)) return item + } + return undefined +} + +export async function* offsetPaginate( + request: (options: PaginationRequestOptions<'offset'> & BaseRequestOptions) => Promise<{ data: T[], paginationInfo: OffsetPagination }>, +): AsyncGenerator { + let page: number | null = 1 + while (page !== null) { + const { data, paginationInfo } = await request({ page, showExpanded: true, pagination: 'offset' }) + for (const item of data) { + yield item + } + page = paginationInfo.next ? paginationInfo.next : null + } +} + +export function daysAgoFromNow(date: Date) { + return Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60 * 24)) +} diff --git a/apps/server-nestjs/src/modules/vault/vault-client.service.ts b/apps/server-nestjs/src/modules/vault/vault-client.service.ts new file mode 100644 index 000000000..a99b7a086 --- /dev/null +++ b/apps/server-nestjs/src/modules/vault/vault-client.service.ts @@ -0,0 +1,100 @@ +import { Inject, Injectable, Logger } from '@nestjs/common' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { generateKVConfigUpdate } from './vault.utils' +import { trace } from '@opentelemetry/api' + +interface VaultAuthMethod { + accessor: string + type: string + description?: string +} + +interface VaultSysAuthResponse { + data: Record +} + +interface VaultIdentityGroupResponse { + data: { + id: string + name: string + alias?: { + id?: string + name?: string + } + } +} + +export interface VaultMetadata { + created_time: string + custom_metadata: Record | null + deletion_time: string + destroyed: boolean + version: number +} + +export interface VaultSecret { + data: T + metadata: VaultMetadata +} + +export interface VaultResponse { + data: VaultSecret +} + +@Injectable() +export class VaultClientService { + private readonly logger = new Logger(VaultClientService.name) + + constructor( + @Inject(ConfigurationService) private readonly config: ConfigurationService, + ) { + } + + private async request(method: string, path: string, options: { body?: any } = {}): Promise { + + const response = await fetch(url, { + method, + headers, + body: options.body ? JSON.stringify(options.body) : undefined, + }) + } + + async read(path: string): Promise>> { + return this.readFromKv(this.config.vaultKvName, path) + } + + async readFromKv(kvName: string, path: string): Promise>> { + if (path.startsWith('/')) path = path.slice(1) + const data = await this.request>('GET', `/v1/${this.config.vaultKvName}/data/${path}`) + } + if (!response.data?.data) { + return { + data: null, + error: { kind: 'InvalidResponse', message: 'Missing "data" field', method: 'GET', path: `/v1/${kvName}/data/${path}` }, + } + } + return { data: response.data.data, error: null } + } + + async write(data: T, path: string): Promise> { + return this.writeToKv(this.config.vaultKvName, data, path) + } + + async writeToKv(kvName: string, data: T, path: string): Promise> { + if (path.startsWith('/')) path = path.slice(1) + await this.request('POST', `/v1/${this.config.vaultKvName}/data/${path}`, { + } + return { data: undefined, error: null } + } + + async destroy(path: string): Promise> { + return this.destroyInKv(this.config.vaultKvName, path) + } + + async destroyInKv(kvName: string, path: string): Promise> { + if (path.startsWith('/')) path = path.slice(1) + await this.request('DELETE', `/v1/${this.config.vaultKvName}/metadata/${path}`) + } + return { data: undefined, error: null } + } +} diff --git a/apps/server-nestjs/src/modules/vault/vault.module.ts b/apps/server-nestjs/src/modules/vault/vault.module.ts new file mode 100644 index 000000000..105b06285 --- /dev/null +++ b/apps/server-nestjs/src/modules/vault/vault.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common' +import { ConfigurationModule } from '@/cpin-module/infrastructure/configuration/configuration.module' +import { InfrastructureModule } from '@/cpin-module/infrastructure/infrastructure.module' +import { VaultClientService } from './vault-client.service' +import { VaultControllerService } from './vault-controller.service' +import { VaultDatastoreService } from './vault-datastore.service' +import { VaultService } from './vault.service' + +@Module({ + imports: [ConfigurationModule, InfrastructureModule], + providers: [VaultService, VaultClientService, VaultControllerService, VaultDatastoreService], + exports: [VaultService], +}) +export class VaultModule {} diff --git a/apps/server-nestjs/src/modules/vault/vault.service.spec.ts b/apps/server-nestjs/src/modules/vault/vault.service.spec.ts new file mode 100644 index 000000000..73f777287 --- /dev/null +++ b/apps/server-nestjs/src/modules/vault/vault.service.spec.ts @@ -0,0 +1,109 @@ +import { Test } from '@nestjs/testing' +import { VaultService } from './vault.service' +import { VaultClientService } from './vault-client.service' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { describe, beforeEach, it, expect, beforeAll, afterAll, afterEach } from 'vitest' +import type { Mocked } from 'vitest' +import { setupServer } from 'msw/node' +import { http, HttpResponse } from 'msw' + +const vaultInternalUrl = 'http://vault.internal' + +const server = setupServer( + http.post(`${vaultInternalUrl}/v1/auth/token/create`, () => { + return HttpResponse.json({ auth: { client_token: 'token' } }) + }), + http.get(`${vaultInternalUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({ data: { data: { secret: 'value' }, metadata: { created_time: '2023-01-01T00:00:00.000Z', version: 1 } } }) + }), + http.post(`${vaultInternalUrl}/v1/kv/data/:path`, () => { + return HttpResponse.json({}) + }), + http.delete(`${vaultInternalUrl}/v1/kv/metadata/:path`, () => { + return new HttpResponse(null, { status: 204 }) + }), +) + +function createVaultServiceTestingModule() { + return Test.createTestingModule({ + providers: [ + VaultService, + VaultClientService, + { + provide: ConfigurationService, + useValue: { + vaultToken: 'token', + vaultUrl: 'http://vault', + vaultInternalUrl, + vaultKvName: 'kv', + } satisfies Partial, + }, + ], + }) +} + +describe('vault', () => { + let service: Mocked + + beforeAll(() => server.listen()) + beforeEach(async () => { + const module = await createVaultServiceTestingModule().compile() + service = module.get(VaultService) + }) + afterEach(() => server.resetHandlers()) + afterAll(() => server.close()) + + describe('getProjectValues', () => { + it('should get project values', async () => { + const result = await service.readProjectValues('project-id') + expect(result).toEqual({ data: { secret: 'value' }, error: null }) + }) + + it('should return empty object if undefined', async () => { + server.use( + http.get(`${vaultInternalUrl}/v1/kv/data/:path`, () => { + return new HttpResponse(null, { status: 404 }) + }), + ) + + const result = await service.readProjectValues('project-id') + expect(result).toEqual({ data: {}, error: null }) + }) + }) + + describe('read', () => { + it('should read secret', async () => { + const result = await service.read('path') + expect(result).toEqual({ + data: { data: { secret: 'value' }, metadata: { created_time: '2023-01-01T00:00:00.000Z', version: 1 } }, + error: null, + }) + }) + + it('should return undefined if 404', async () => { + server.use( + http.get(`${vaultInternalUrl}/v1/kv/data/:path`, () => { + return new HttpResponse(null, { status: 404 }) + }), + ) + + const result = await service.read('path') + expect(result.data).toBeNull() + expect(result.error).toMatchObject({ kind: 'NotFound', status: 404 }) + }) + }) + + describe('write', () => { + it('should write secret', async () => { + const result = await service.write({ secret: 'value' }, 'path') + expect(result).toEqual({ data: undefined, error: null }) + }) + }) + + describe('destroy', () => { + it('should destroy secret', async () => { + const result = await service.destroy('path') + expect(result).toEqual({ data: undefined, error: null }) + }) + }) +}) diff --git a/apps/server-nestjs/src/modules/vault/vault.service.ts b/apps/server-nestjs/src/modules/vault/vault.service.ts new file mode 100644 index 000000000..584c3c5eb --- /dev/null +++ b/apps/server-nestjs/src/modules/vault/vault.service.ts @@ -0,0 +1,127 @@ +import { Inject, Injectable } from '@nestjs/common' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import { VaultClientService } from './vault-client.service' +import type { VaultError, VaultResult, VaultSecret } from './vault-client.service' +import { trace } from '@opentelemetry/api' + +const tracer = trace.getTracer('vault-service') + +@Injectable() +export class VaultService { + constructor( + @Inject(VaultClientService) private readonly vaultClientService: VaultClientService, + @Inject(ConfigurationService) private readonly config: ConfigurationService, + ) { + } + + async destroy(path: string): Promise> { + return tracer.startActiveSpan('destroy', async (span) => { + try { + span.setAttribute('vault.path', path) + return await this.vaultClientService.destroy(path) + } finally { + span.end() + } + }) + } + + async readProjectValues(projectId: string): Promise>> { + return tracer.startActiveSpan('readProjectValues', async (span) => { + const path = this.config.projectRootPath + ? `${this.config.projectRootPath}/${projectId}` + : projectId + try { + span.setAttribute('vault.path', path) + const secret = await this.vaultClientService.read(path) + if (secret.error) { + if (secret.error.kind === 'NotFound') return { data: {}, error: null } + return { data: null, error: secret.error } + } + return { data: secret.data.data || {}, error: null } + } finally { + span.end() + } + }) + } + + async readGitlabMirrorCreds(projectSlug: string, repoName: string) { + return tracer.startActiveSpan('readGitlabMirrorCreds', async (span) => { + const vaultCredsPath = `${this.config.projectRootPath}/${projectSlug}/${repoName}-mirror` + try { + span.setAttribute('vault.path', vaultCredsPath) + span.setAttribute('project.slug', projectSlug) + span.setAttribute('repo.name', repoName) + return await this.read(vaultCredsPath) + } finally { + span.end() + } + }) + } + + async writeGitlabMirrorCreds(projectSlug: string, repoName: string, data: Record) { + return tracer.startActiveSpan('writeGitlabMirrorCreds', async (span) => { + const vaultCredsPath = `${this.config.projectRootPath}/${projectSlug}/${repoName}-mirror` + try { + span.setAttribute('vault.path', vaultCredsPath) + span.setAttribute('project.slug', projectSlug) + span.setAttribute('repo.name', repoName) + return await this.write(data, vaultCredsPath) + } finally { + span.end() + } + }) + } + + async deleteGitlabMirrorCreds(projectSlug: string, repoName: string) { + return tracer.startActiveSpan('deleteGitlabMirrorCreds', async (span) => { + const vaultCredsPath = `${this.config.projectRootPath}/${projectSlug}/${repoName}-mirror` + try { + span.setAttribute('vault.path', vaultCredsPath) + span.setAttribute('project.slug', projectSlug) + span.setAttribute('repo.name', repoName) + const result = await this.destroy(vaultCredsPath) + if (result.error?.kind === 'NotFound') return { data: undefined, error: null } + return result + } finally { + span.end() + } + }) + } + + async readMirrorCreds(projectSlug: string) { + return tracer.startActiveSpan('readMirrorCreds', async (span) => { + const vaultPath = `${this.config.projectRootPath}/${projectSlug}/tech/GITLAB_MIRROR` + try { + span.setAttribute('vault.path', vaultPath) + span.setAttribute('project.slug', projectSlug) + return await this.read(vaultPath) + } finally { + span.end() + } + }) + } + + async writeMirrorCreds(projectSlug: string, creds: Record) { + return tracer.startActiveSpan('writeMirrorCreds', async (span) => { + const vaultPath = `${this.config.projectRootPath}/${projectSlug}/tech/GITLAB_MIRROR` + try { + span.setAttribute('vault.path', vaultPath) + span.setAttribute('project.slug', projectSlug) + return await this.write(creds, vaultPath) + } finally { + span.end() + } + }) + } + + async writeMirrorTriggerToken(secret: Record) { + return tracer.startActiveSpan('writeMirrorTriggerToken', async (span) => { + try { + span.setAttribute('vault.path', 'GITLAB') + return await this.write(secret, 'GITLAB') + } finally { + span.end() + } + }) + } +} diff --git a/apps/server-nestjs/src/prisma/schema/project.prisma b/apps/server-nestjs/src/prisma/schema/project.prisma index 833845eee..d45ccf451 100644 --- a/apps/server-nestjs/src/prisma/schema/project.prisma +++ b/apps/server-nestjs/src/prisma/schema/project.prisma @@ -5,6 +5,7 @@ model Environment { memory Float @db.Real cpu Float @db.Real gpu Float @db.Real + autosync Boolean @default(true) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt clusterId String @db.Uuid diff --git a/apps/server-nestjs/test/gitlab.e2e-spec.ts b/apps/server-nestjs/test/gitlab.e2e-spec.ts new file mode 100644 index 000000000..0d0252e18 --- /dev/null +++ b/apps/server-nestjs/test/gitlab.e2e-spec.ts @@ -0,0 +1,268 @@ +import type { TestingModule } from '@nestjs/testing' +import { Test } from '@nestjs/testing' +import { GitlabModule } from '@/modules/gitlab/gitlab.module' +import { GitlabControllerService } from '@/modules/gitlab/gitlab-controller.service' +import { GitlabClientService } from '@/modules/gitlab/gitlab-client.service' +import { GitlabService } from '@/modules/gitlab/gitlab.service' +import { PrismaService } from '@/cpin-module/infrastructure/database/prisma.service' +import { ConfigurationModule } from '../src/cpin-module/infrastructure/configuration/configuration.module' +import { InfrastructureModule } from '@/cpin-module/infrastructure/infrastructure.module' +import { VaultService } from '@/modules/vault/vault.service' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { faker } from '@faker-js/faker' +import { projectSelect } from '@/modules/gitlab/gitlab-datastore.service' +import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' +import z from 'zod' +import type { ExpandedUserSchema } from '@gitbeaker/core' + +async function sleep(ms: number) { + await new Promise(resolve => setTimeout(resolve, ms)) +} + +async function retryUntil( + getValue: () => Promise, + predicate: (value: T) => boolean, + options: { retries?: number, delayMs?: number } = {}, +) { + const retries = options.retries ?? 30 + const delayMs = options.delayMs ?? 1000 + + let lastValue = await getValue() + for (let attempt = 0; attempt < retries; attempt++) { + if (predicate(lastValue)) return lastValue + await sleep(delayMs) + lastValue = await getValue() + } + return lastValue +} + +const canRunGitlabE2E + = process.env.E2E === 'true' + && Boolean(process.env.GITLAB_URL) + && Boolean(process.env.GITLAB_TOKEN) + && Boolean(process.env.VAULT_URL) + && Boolean(process.env.VAULT_TOKEN) + && Boolean(process.env.PROJECTS_ROOT_DIR) + +const describeWithGitLab = describe.runIf(canRunGitlabE2E) + +describeWithGitLab('GitlabController (e2e)', {}, () => { + let moduleRef: TestingModule + let gitlabController: GitlabControllerService + let gitlabService: GitlabService + let gitlabClient: GitlabClientService + let vaultService: VaultService + let prisma: PrismaService + let config: ConfigurationService + + let vaultProbePath: string + + let testProjectId: string + let testProjectSlug: string + let ownerId: string + let ownerUser: ExpandedUserSchema + + beforeAll(async () => { + moduleRef = await Test.createTestingModule({ + imports: [GitlabModule, ConfigurationModule, InfrastructureModule], + }).compile() + + await moduleRef.init() + + gitlabController = moduleRef.get(GitlabControllerService) + gitlabService = moduleRef.get(GitlabService) + gitlabClient = moduleRef.get(GitlabClientService) + vaultService = moduleRef.get(VaultService) + prisma = moduleRef.get(PrismaService) + config = moduleRef.get(ConfigurationService) + + ownerId = faker.string.uuid() + testProjectId = faker.string.uuid() + testProjectSlug = faker.helpers.slugify(`test-project-${faker.string.uuid()}`) + + const ownerEmail = `test-owner-${ownerId}@example.com` + + vaultProbePath = `${config.projectRootPath}/__e2e-probe-${testProjectSlug}` + const probeWrite = await vaultService.write({ ok: true }, vaultProbePath) + if (probeWrite.error) { + throw new Error(`Vault probe write failed: ${probeWrite.error.kind} (path: ${vaultProbePath})`) + } + const probeRead = await vaultService.read(vaultProbePath) + if (probeRead.error) { + throw new Error(`Vault probe read failed: ${probeRead.error.kind} (path: ${vaultProbePath})`) + } + + // Create owner in GitLab + ownerUser = await gitlabClient.Users.create({ + name: 'Test Owner', + password: faker.internet.password({ length: 24 }), + username: `test-owner-${ownerId}`, + email: ownerEmail, + skipConfirmation: true, + }) + + // Create owner in DB + await prisma.user.create({ + data: { + id: ownerId, + email: ownerUser.email.toLowerCase(), + firstName: 'Test', + lastName: 'Owner', + type: 'human', + }, + }) + }) + + afterAll(async () => { + if (vaultProbePath) { + await vaultService.destroy(vaultProbePath).catch(() => {}) + } + + // Clean GitLab group + if (testProjectSlug && config.projectRootPath) { + const fullPath = `${config.projectRootPath}/${testProjectSlug}` + const group = await gitlabService.getGroupByPath(fullPath) + if (group) { + await gitlabService.deleteGroup(group.id).catch(() => {}) + } + } + + // Clean Vault + if (testProjectSlug && config.projectRootPath) { + const vaultPath = `${config.projectRootPath}/${testProjectSlug}` + await vaultService.destroy(`${vaultPath}/tech/GITLAB_MIRROR`).catch(() => {}) + await vaultService.destroy(`${vaultPath}/app-mirror`).catch(() => {}) + } + + // Clean DB + if (prisma) { + await prisma.projectMembers.deleteMany({ where: { projectId: testProjectId } }).catch(() => {}) + await prisma.project.deleteMany({ where: { id: testProjectId } }).catch(() => {}) + await prisma.user.deleteMany({ where: { id: ownerId } }).catch(() => {}) + } + + await moduleRef.close() + + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('should reconcile and create project group in GitLab and Vault secrets', async () => { + // Create Project in DB + await prisma.project.create({ + data: { + id: testProjectId, + slug: testProjectSlug, + name: testProjectSlug, + ownerId, + description: 'E2E Test Project', + hprodCpu: 0, + hprodGpu: 0, + hprodMemory: 0, + prodCpu: 0, + prodGpu: 0, + prodMemory: 0, + }, + }) + + await prisma.repository.create({ + data: { + projectId: testProjectId, + internalRepoName: 'app', + externalRepoUrl: 'https://example.com/example.git', + isPrivate: false, + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act + await gitlabController.handleUpsert(project) + + // Assert + const groupPath = `${config.projectRootPath}/${testProjectSlug}` + const group = z.object({ + id: z.number(), + name: z.string(), + full_path: z.string(), + }).parse(await gitlabService.getGroupByPath(groupPath)) + expect(group.full_path).toBe(groupPath) + + // Check membership + const members = await gitlabService.getGroupMembers(group.id) + const isMember = members.some(m => m.id === ownerUser.id) + expect(isMember).toBe(true) + + const repoVaultPath = `${config.projectRootPath}/${testProjectSlug}/app-mirror` + const repoSecret = await retryUntil( + () => vaultService.read(repoVaultPath), + secret => secret.error === null, + { retries: 30, delayMs: 1000 }, + ) + expect(repoSecret.error).toBeNull() + expect(repoSecret.data?.data?.GIT_OUTPUT_USER).toBeTruthy() + expect(repoSecret.data?.data?.GIT_OUTPUT_PASSWORD).toBeTruthy() + }, 180000) + + it('should add member to GitLab group when added in DB', async () => { + // Create user in GitLab + const newUserId = faker.string.uuid() + const newUser = await gitlabClient.Users.create({ + email: faker.internet.email().toLowerCase(), + username: faker.internet.username(), + name: `${faker.person.firstName()} ${faker.person.lastName()}`, + password: faker.internet.password({ length: 24 }), + skipConfirmation: true, + }) + + // Create user in DB + await prisma.user.create({ + data: { + id: newUserId, + email: newUser.email, + firstName: 'Test', + lastName: 'User', + type: 'human', + }, + }) + + // Add member to project in DB + await prisma.projectMembers.create({ + data: { + projectId: testProjectId, + userId: newUserId, + roleIds: [], // No roles for now + }, + }) + + const project = await prisma.project.findUniqueOrThrow({ + where: { id: testProjectId }, + select: projectSelect, + }) + + // Act + await gitlabController.handleUpsert(project) + + // Assert + const groupPath = `${config.projectRootPath}/${testProjectSlug}` + const group = z.object({ + id: z.number(), + }).parse(await gitlabService.getGroupByPath(groupPath)) + + const isNewMemberPresent = await retryUntil( + async () => { + const members = await gitlabService.getGroupMembers(group.id) + return members.some(m => m.username === newUser.username || m.email?.toLowerCase() === newUser.email.toLowerCase()) + }, + Boolean, + { retries: 30, delayMs: 1000 }, + ) + expect(isNewMemberPresent).toBe(true) + + await prisma.projectMembers.deleteMany({ where: { userId: newUserId } }).catch(() => {}) + await prisma.user.delete({ where: { id: newUserId } }).catch(() => {}) + }, 180000) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6aeb10fd..342c03c88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -404,6 +404,9 @@ importers: '@gitbeaker/core': specifier: ^40.6.0 version: 40.6.0 + '@gitbeaker/requester-utils': + specifier: ^40.6.0 + version: 40.6.0 '@gitbeaker/rest': specifier: ^40.6.0 version: 40.6.0 @@ -485,6 +488,9 @@ importers: fastify-keycloak-adapter: specifier: 2.3.2 version: 2.3.2(patch_hash=6846b953fc520dd1ca6cb2e790cf190cbc3ed9fa9ff69739100458c520293447) + js-yaml: + specifier: ^4.1.1 + version: 4.1.1 json-2-csv: specifier: ^5.5.10 version: 5.5.10 From dbacd0f7dc82f4ca8ded391d92b5ca4e6040f1cf Mon Sep 17 00:00:00 2001 From: William Phetsinorath Date: Thu, 12 Mar 2026 12:14:18 +0100 Subject: [PATCH 4/4] refactor(vault): migrate Vault plugin to NestJS Signed-off-by: William Phetsinorath --- .../src/modules/vault/vault-client.service.ts | 399 +++++++++++++++++- .../vault/vault-controller.service.spec.ts | 72 ++++ .../modules/vault/vault-controller.service.ts | 111 +++++ .../modules/vault/vault-datastore.service.ts | 59 +++ .../src/modules/vault/vault.service.ts | 269 ++++++++++++ .../src/modules/vault/vault.utils.ts | 17 + 6 files changed, 918 insertions(+), 9 deletions(-) create mode 100644 apps/server-nestjs/src/modules/vault/vault-controller.service.spec.ts create mode 100644 apps/server-nestjs/src/modules/vault/vault-controller.service.ts create mode 100644 apps/server-nestjs/src/modules/vault/vault-datastore.service.ts create mode 100644 apps/server-nestjs/src/modules/vault/vault.utils.ts diff --git a/apps/server-nestjs/src/modules/vault/vault-client.service.ts b/apps/server-nestjs/src/modules/vault/vault-client.service.ts index a99b7a086..1d0e9443b 100644 --- a/apps/server-nestjs/src/modules/vault/vault-client.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-client.service.ts @@ -1,6 +1,5 @@ import { Inject, Injectable, Logger } from '@nestjs/common' import { ConfigurationService } from '@/cpin-module/infrastructure/configuration/configuration.service' -import { generateKVConfigUpdate } from './vault.utils' import { trace } from '@opentelemetry/api' interface VaultAuthMethod { @@ -41,6 +40,42 @@ export interface VaultResponse { data: VaultSecret } +interface VaultKv1Response { + data: T +} + +export type VaultError + = { kind: 'NotConfigured', message: string } + | { kind: 'NotFound', status: 404, method: string, path: string, statusText?: string } + | { kind: 'HttpError', status: number, method: string, path: string, statusText: string } + | { kind: 'InvalidResponse', message: string, method: string, path: string } + | { kind: 'ParseError', message: string, method: string, path: string } + | { kind: 'Unexpected', message: string, method: string, path: string } + +export type VaultResult + = | { data: T, error: null } + | { data: null, error: VaultError } + +const tracer = trace.getTracer('vault-client-service') + +interface VaultListResponse { + data: { + keys: string[] + } +} + +interface VaultRoleIdResponse { + data: { + role_id: string + } +} + +interface VaultSecretIdResponse { + data: { + secret_id: string + } +} + @Injectable() export class VaultClientService { private readonly logger = new Logger(VaultClientService.name) @@ -50,12 +85,102 @@ export class VaultClientService { ) { } - private async request(method: string, path: string, options: { body?: any } = {}): Promise { + private deriveAlternateKvFromPath(kvName: string, path: string): { kvName: string, path: string } | null { + const normalized = path.startsWith('/') ? path.slice(1) : path + const index = normalized.indexOf('/') + if (index <= 0) return null + + const candidateKvName = normalized.slice(0, index) + if (candidateKvName === kvName) return null + + const candidatePath = normalized.slice(index + 1) + if (candidatePath.length === 0) return null + + return { kvName: candidateKvName, path: candidatePath } + } + + private async fetch( + path: string, + options: { method?: string, body?: any } = {}, + ): Promise> { + const method = options.method ?? 'GET' + return tracer.startActiveSpan('fetch', async (span) => { + try { + span.setAttribute('vault.method', method) + span.setAttribute('vault.path', path) + + if (!this.config.vaultInternalUrl) { + return { + data: null, + error: { kind: 'NotConfigured', message: 'VAULT_INTERNAL_URL is required' }, + } satisfies VaultResult + } + if (!this.config.vaultToken) { + return { + data: null, + error: { kind: 'NotConfigured', message: 'VAULT_TOKEN is required' }, + } satisfies VaultResult + } - const response = await fetch(url, { - method, - headers, - body: options.body ? JSON.stringify(options.body) : undefined, + const url = `${this.config.vaultInternalUrl}${path}` + const headers: Record = { + 'Content-Type': 'application/json', + 'X-Vault-Token': this.config.vaultToken, + } + + let response: Response + try { + response = await fetch(url, { + method, + headers, + body: options.body ? JSON.stringify(options.body) : undefined, + }) + } catch (error) { + return { + data: null, + error: { + kind: 'Unexpected', + message: error instanceof Error ? error.message : String(error), + method, + path, + }, + } satisfies VaultResult + } + + span.setAttribute('vault.http.status', response.status) + + if (response.status === 404) { + return { + data: null, + error: { kind: 'NotFound', status: 404, method, path, statusText: response.statusText }, + } satisfies VaultResult + } + if (!response.ok) { + return { + data: null, + error: { kind: 'HttpError', status: response.status, statusText: response.statusText, method, path }, + } satisfies VaultResult + } + if (response.status === 204) { + return { data: null, error: null } satisfies VaultResult + } + + try { + return { data: await response.json(), error: null } satisfies VaultResult + } catch (error) { + return { + data: null, + error: { + kind: 'ParseError', + message: error instanceof Error ? error.message : String(error), + method, + path, + }, + } satisfies VaultResult + } + } finally { + span.end() + } }) } @@ -65,7 +190,56 @@ export class VaultClientService { async readFromKv(kvName: string, path: string): Promise>> { if (path.startsWith('/')) path = path.slice(1) - const data = await this.request>('GET', `/v1/${this.config.vaultKvName}/data/${path}`) + const response = await this.fetch>(`/v1/${kvName}/data/${path}`, { method: 'GET' }) + if (response.error) { + if (response.error.kind === 'NotFound') { + const alternate = this.deriveAlternateKvFromPath(kvName, path) + if (alternate) { + const fallback = await this.fetch>(`/v1/${alternate.kvName}/data/${alternate.path}`, { method: 'GET' }) + if (!fallback.error && fallback.data?.data) return { data: fallback.data.data, error: null } + if (fallback.error && fallback.error.kind !== 'NotFound') return { data: null, error: fallback.error } + } + + const kv1 = await this.fetch>(`/v1/${kvName}/${path}`, { method: 'GET' }) + if (!kv1.error && kv1.data) { + return { + data: { + data: kv1.data.data, + metadata: { + created_time: '', + custom_metadata: null, + deletion_time: '', + destroyed: false, + version: 1, + }, + }, + error: null, + } + } + if (alternate) { + const kv1Fallback = await this.fetch>(`/v1/${alternate.kvName}/${alternate.path}`, { method: 'GET' }) + if (!kv1Fallback.error && kv1Fallback.data) { + return { + data: { + data: kv1Fallback.data.data, + metadata: { + created_time: '', + custom_metadata: null, + deletion_time: '', + destroyed: false, + version: 1, + }, + }, + error: null, + } + } + if (kv1Fallback.error) return { data: null, error: kv1Fallback.error } + } + } + if (response.error.kind !== 'NotFound') { + this.logger.error(`Failed to read vault path ${path}: ${response.error.kind}`) + } + return { data: null, error: response.error } } if (!response.data?.data) { return { @@ -82,7 +256,28 @@ export class VaultClientService { async writeToKv(kvName: string, data: T, path: string): Promise> { if (path.startsWith('/')) path = path.slice(1) - await this.request('POST', `/v1/${this.config.vaultKvName}/data/${path}`, { + const response = await this.fetch(`/v1/${kvName}/data/${path}`, { method: 'POST', body: { data } }) + if (response.error) { + if (response.error.kind === 'NotFound') { + const alternate = this.deriveAlternateKvFromPath(kvName, path) + if (alternate) { + const fallback = await this.fetch(`/v1/${alternate.kvName}/data/${alternate.path}`, { method: 'POST', body: { data } }) + if (!fallback.error) return { data: undefined, error: null } + if (fallback.error.kind !== 'NotFound') return { data: null, error: fallback.error } + } + + const kv1 = await this.fetch(`/v1/${kvName}/${path}`, { method: 'POST', body: data }) + if (!kv1.error) return { data: undefined, error: null } + if (kv1.error.kind !== 'NotFound') return { data: null, error: kv1.error } + + if (alternate) { + const kv1Fallback = await this.fetch(`/v1/${alternate.kvName}/${alternate.path}`, { method: 'POST', body: data }) + if (!kv1Fallback.error) return { data: undefined, error: null } + if (kv1Fallback.error.kind !== 'NotFound') return { data: null, error: kv1Fallback.error } + } + } + this.logger.error(`Failed to write vault path ${path}: ${response.error.kind}`) + return { data: null, error: response.error } } return { data: undefined, error: null } } @@ -93,8 +288,194 @@ export class VaultClientService { async destroyInKv(kvName: string, path: string): Promise> { if (path.startsWith('/')) path = path.slice(1) - await this.request('DELETE', `/v1/${this.config.vaultKvName}/metadata/${path}`) + const response = await this.fetch(`/v1/${kvName}/metadata/${path}`, { method: 'DELETE' }) + if (response.error) { + if (response.error.kind === 'NotFound') { + const alternate = this.deriveAlternateKvFromPath(kvName, path) + if (alternate) { + const fallback = await this.fetch(`/v1/${alternate.kvName}/metadata/${alternate.path}`, { method: 'DELETE' }) + if (!fallback.error) return { data: undefined, error: null } + if (fallback.error.kind !== 'NotFound') return { data: null, error: fallback.error } + } + + const kv1 = await this.fetch(`/v1/${kvName}/${path}`, { method: 'DELETE' }) + if (!kv1.error) return { data: undefined, error: null } + if (kv1.error.kind !== 'NotFound') return { data: null, error: kv1.error } + + if (alternate) { + const kv1Fallback = await this.fetch(`/v1/${alternate.kvName}/${alternate.path}`, { method: 'DELETE' }) + if (!kv1Fallback.error) return { data: undefined, error: null } + if (kv1Fallback.error.kind !== 'NotFound') return { data: null, error: kv1Fallback.error } + } + + return { data: undefined, error: null } + } + this.logger.error(`Failed to destroy vault path ${path}: ${response.error.kind}`) + return { data: null, error: response.error } + } + return { data: undefined, error: null } + } + + async listInKv(kvName: string, path: string): Promise> { + if (path.startsWith('/')) path = path.slice(1) + const normalized = path.length === 0 ? '' : path.endsWith('/') ? path : `${path}/` + const response = await this.fetch(`/v1/${kvName}/metadata/${normalized}`, { method: 'LIST' }) + if (response.error) { + if (response.error.kind === 'NotFound') { + const alternate = this.deriveAlternateKvFromPath(kvName, path) + if (alternate) { + const fallbackNormalized = alternate.path.length === 0 ? '' : alternate.path.endsWith('/') ? alternate.path : `${alternate.path}/` + const fallback = await this.fetch(`/v1/${alternate.kvName}/metadata/${fallbackNormalized}`, { method: 'LIST' }) + if (!fallback.error && fallback.data?.data?.keys) return { data: fallback.data.data.keys, error: null } + if (fallback.error?.kind === 'NotFound') return { data: [], error: null } + if (fallback.error) return { data: null, error: fallback.error } + } + + const kv1 = await this.fetch(`/v1/${kvName}/${normalized}`, { method: 'LIST' }) + if (!kv1.error && kv1.data?.data?.keys) return { data: kv1.data.data.keys, error: null } + if (kv1.error?.kind === 'NotFound') return { data: [], error: null } + + if (alternate) { + const fallbackNormalized = alternate.path.length === 0 ? '' : alternate.path.endsWith('/') ? alternate.path : `${alternate.path}/` + const kv1Fallback = await this.fetch(`/v1/${alternate.kvName}/${fallbackNormalized}`, { method: 'LIST' }) + if (!kv1Fallback.error && kv1Fallback.data?.data?.keys) return { data: kv1Fallback.data.data.keys, error: null } + if (kv1Fallback.error?.kind === 'NotFound') return { data: [], error: null } + if (kv1Fallback.error) return { data: null, error: kv1Fallback.error } + } + + return { data: [], error: null } + } + return { data: null, error: response.error } } + if (!response.data?.data?.keys) { + return { + data: null, + error: { kind: 'InvalidResponse', message: 'Missing "data.keys" field', method: 'LIST', path: `/v1/${kvName}/metadata/${normalized}` }, + } + } + return { data: response.data.data.keys, error: null } + } + + async upsertPolicyAcl(policyName: string, data: any): Promise> { + const response = await this.fetch(`/v1/sys/policies/acl/${policyName}`, { method: 'POST', body: data }) + if (response.error) return { data: null, error: response.error } + return { data: undefined, error: null } + } + + async deletePolicyAcl(policyName: string): Promise> { + const response = await this.fetch(`/v1/sys/policies/acl/${policyName}`, { method: 'DELETE' }) + if (response.error) return { data: null, error: response.error } + return { data: undefined, error: null } + } + + async createMount(name: string, data: any): Promise> { + const response = await this.fetch(`/v1/sys/mounts/${name}`, { method: 'POST', body: data }) + if (response.error) return { data: null, error: response.error } + return { data: undefined, error: null } + } + + async updateMount(name: string, data: any): Promise> { + const response = await this.fetch(`/v1/sys/mounts/${name}/tune`, { method: 'POST', body: data }) + if (response.error) return { data: null, error: response.error } + return { data: undefined, error: null } + } + + async deleteMount(name: string): Promise> { + const response = await this.fetch(`/v1/sys/mounts/${name}`, { method: 'DELETE' }) + if (response.error) return { data: null, error: response.error } + return { data: undefined, error: null } + } + + async upsertRole(roleName: string, policies: string[]): Promise> { + const response = await this.fetch(`/v1/auth/approle/role/${roleName}`, { + method: 'POST', + body: { + secret_id_num_uses: '0', + secret_id_ttl: '0', + token_max_ttl: '0', + token_num_uses: '0', + token_ttl: '0', + token_type: 'batch', + token_policies: policies, + }, + }) + if (response.error) return { data: null, error: response.error } + return { data: undefined, error: null } + } + + async deleteRole(roleName: string): Promise> { + const response = await this.fetch(`/v1/auth/approle/role/${roleName}`, { method: 'DELETE' }) + if (response.error) return { data: null, error: response.error } + return { data: undefined, error: null } + } + + async getRoleId(roleName: string): Promise> { + const path = `/v1/auth/approle/role/${roleName}/role-id` + const response = await this.fetch(path, { method: 'GET' }) + if (response.error) return { data: null, error: response.error } + const roleId = response.data?.data?.role_id + if (!roleId) { + return { data: null, error: { kind: 'InvalidResponse', message: `Vault role-id not found for role ${roleName}`, method: 'GET', path } } + } + return { data: roleId, error: null } + } + + async generateSecretId(roleName: string): Promise> { + const path = `/v1/auth/approle/role/${roleName}/secret-id` + const response = await this.fetch(path, { method: 'POST' }) + if (response.error) return { data: null, error: response.error } + const secretId = response.data?.data?.secret_id + if (!secretId) { + return { data: null, error: { kind: 'InvalidResponse', message: `Vault secret-id not generated for role ${roleName}`, method: 'POST', path } } + } + return { data: secretId, error: null } + } + + async getAuthMethods(): Promise>> { + const path = '/v1/sys/auth' + const response = await this.fetch(path, { method: 'GET' }) + if (response.error) return { data: null, error: response.error } + return { data: response.data?.data ?? {}, error: null } + } + + async upsertIdentityGroup(groupName: string, policies: string[]): Promise> { + const response = await this.fetch(`/v1/identity/group/name/${groupName}`, { + method: 'POST', + body: { + name: groupName, + type: 'external', + policies, + }, + }) + if (response.error) return { data: null, error: response.error } + return { data: undefined, error: null } + } + + async getIdentityGroup(groupName: string): Promise> { + const response = await this.fetch(`/v1/identity/group/name/${groupName}`, { method: 'GET' }) + if (response.error) return { data: null, error: response.error } + if (!response.data) { + return { data: null, error: { kind: 'InvalidResponse', message: 'Empty response', method: 'GET', path: `/v1/identity/group/name/${groupName}` } } + } + return { data: response.data, error: null } + } + + async deleteIdentityGroup(groupName: string): Promise> { + const response = await this.fetch(`/v1/identity/group/name/${groupName}`, { method: 'DELETE' }) + if (response.error) return { data: null, error: response.error } + return { data: undefined, error: null } + } + + async createGroupAlias(groupAliasName: string, mountAccessor: string, canonicalId: string): Promise> { + const response = await this.fetch('/v1/identity/group-alias', { + method: 'POST', + body: { + name: groupAliasName, + mount_accessor: mountAccessor, + canonical_id: canonicalId, + }, + }) + if (response.error) return { data: null, error: response.error } return { data: undefined, error: null } } } diff --git a/apps/server-nestjs/src/modules/vault/vault-controller.service.spec.ts b/apps/server-nestjs/src/modules/vault/vault-controller.service.spec.ts new file mode 100644 index 000000000..c43487810 --- /dev/null +++ b/apps/server-nestjs/src/modules/vault/vault-controller.service.spec.ts @@ -0,0 +1,72 @@ +import { Test } from '@nestjs/testing' +import type { TestingModule } from '@nestjs/testing' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import type { Mocked } from 'vitest' +import { VaultControllerService } from './vault-controller.service' +import { VaultDatastoreService } from './vault-datastore.service' +import { VaultService } from './vault.service' + +function createVaultControllerServiceTestingModule() { + return Test.createTestingModule({ + providers: [ + VaultControllerService, + { + provide: VaultDatastoreService, + useValue: { + getAllProjects: vi.fn(), + getAllZones: vi.fn(), + } satisfies Partial, + }, + { + provide: VaultService, + useValue: { + upsertProject: vi.fn().mockResolvedValue({ data: undefined, error: null }), + upsertZone: vi.fn().mockResolvedValue({ data: undefined, error: null }), + deleteProject: vi.fn().mockResolvedValue({ data: undefined, error: null }), + destroyProjectSecrets: vi.fn().mockResolvedValue({ data: undefined, error: null }), + } satisfies Partial, + }, + ], + }) +} + +describe('vaultControllerService', () => { + let service: VaultControllerService + let datastore: Mocked + let vault: Mocked + + beforeEach(async () => { + const module: TestingModule = await createVaultControllerServiceTestingModule().compile() + service = module.get(VaultControllerService) + datastore = module.get(VaultDatastoreService) + vault = module.get(VaultService) + }) + + it('should be defined', () => { + expect(service).toBeDefined() + }) + + it('should reconcile on cron', async () => { + datastore.getAllProjects.mockResolvedValue([{ slug: 'p1' }, { slug: 'p2' }] as any) + datastore.getAllZones.mockResolvedValue([{ slug: 'z1' }] as any) + + await service.handleCron() + + expect(datastore.getAllProjects).toHaveBeenCalled() + expect(datastore.getAllZones).toHaveBeenCalled() + expect(vault.upsertProject).toHaveBeenCalledTimes(2) + expect(vault.upsertZone).toHaveBeenCalledTimes(1) + expect(vault.upsertZone).toHaveBeenCalledWith('z1') + }) + + it('should upsert project on event', async () => { + await service.handleUpsert({ slug: 'p1' } as any) + expect(vault.upsertProject).toHaveBeenCalledWith({ slug: 'p1' }) + }) + + it('should delete project and destroy secrets on event', async () => { + await service.handleDelete({ slug: 'p1' } as any) + expect(vault.deleteProject).toHaveBeenCalledWith('p1') + expect(vault.destroyProjectSecrets).toHaveBeenCalledWith('p1') + }) +}) diff --git a/apps/server-nestjs/src/modules/vault/vault-controller.service.ts b/apps/server-nestjs/src/modules/vault/vault-controller.service.ts new file mode 100644 index 000000000..e88ab698b --- /dev/null +++ b/apps/server-nestjs/src/modules/vault/vault-controller.service.ts @@ -0,0 +1,111 @@ +import { Inject, Injectable, Logger } from '@nestjs/common' +import { OnEvent } from '@nestjs/event-emitter' +import { Cron, CronExpression } from '@nestjs/schedule' +import { VaultDatastoreService } from './vault-datastore.service' +import type { ProjectWithDetails } from './vault-datastore.service' +import { VaultService } from './vault.service' +import { trace } from '@opentelemetry/api' + +const tracer = trace.getTracer('vault-controller-service') + +@Injectable() +export class VaultControllerService { + private readonly logger = new Logger(VaultControllerService.name) + + constructor( + @Inject(VaultDatastoreService) private readonly vaultDatastore: VaultDatastoreService, + @Inject(VaultService) private readonly vault: VaultService, + ) { + this.logger.log('VaultControllerService initialized') + } + + @OnEvent('project.upsert') + async handleUpsert(project: ProjectWithDetails) { + return tracer.startActiveSpan('handleUpsert', async (span) => { + try { + span.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project upsert for ${project.slug}`) + const result = await this.vault.upsertProject(project) + if (result.error) { + if (result.error.kind === 'NotConfigured') return + throw new Error(`Vault upsertProject failed: ${result.error.kind}`) + } + } catch (error) { + if (error instanceof Error) span.recordException(error) + throw error + } finally { + span.end() + } + }) + } + + @OnEvent('project.delete') + async handleDelete(project: ProjectWithDetails) { + return tracer.startActiveSpan('handleDelete', async (span) => { + try { + span.setAttribute('project.slug', project.slug) + this.logger.log(`Handling project delete for ${project.slug}`) + const deleted = await this.vault.deleteProject(project.slug) + if (deleted.error) { + if (deleted.error.kind === 'NotConfigured') return + throw new Error(`Vault deleteProject failed: ${deleted.error.kind}`) + } + + const destroyed = await this.vault.destroyProjectSecrets(project.slug) + if (destroyed.error) { + if (destroyed.error.kind === 'NotConfigured') return + throw new Error(`Vault destroyProjectSecrets failed: ${destroyed.error.kind}`) + } + } catch (error) { + if (error instanceof Error) span.recordException(error) + throw error + } finally { + span.end() + } + }) + } + + @Cron(CronExpression.EVERY_HOUR) + async handleCron() { + return tracer.startActiveSpan('handleCron', async (span) => { + try { + this.logger.log('Starting Vault reconciliation') + const [projects, zones] = await Promise.all([ + this.vaultDatastore.getAllProjects(), + this.vaultDatastore.getAllZones(), + ]) + + span.setAttribute('vault.projects.count', projects.length) + span.setAttribute('vault.zones.count', zones.length) + + const results = await Promise.allSettled([ + ...projects.map(async (p) => { + const result = await this.vault.upsertProject(p) + if (result.error) { + if (result.error.kind === 'NotConfigured') return + throw new Error(`Vault upsertProject failed: ${result.error.kind}`) + } + }), + ...zones.map(async (z) => { + const result = await this.vault.upsertZone(z.slug) + if (result.error) { + if (result.error.kind === 'NotConfigured') return + throw new Error(`Vault upsertZone failed: ${result.error.kind}`) + } + }), + ]) + + results.forEach((result) => { + if (result.status === 'rejected') { + this.logger.error(`Reconciliation failed: ${result.reason}`) + } + }) + } catch (error) { + if (error instanceof Error) span.recordException(error) + throw error + } finally { + span.end() + } + }) + } +} diff --git a/apps/server-nestjs/src/modules/vault/vault-datastore.service.ts b/apps/server-nestjs/src/modules/vault/vault-datastore.service.ts new file mode 100644 index 000000000..c5ab16e7f --- /dev/null +++ b/apps/server-nestjs/src/modules/vault/vault-datastore.service.ts @@ -0,0 +1,59 @@ +import { Inject, Injectable } from '@nestjs/common' +import type { Prisma } from '@prisma/client' +import { PrismaService } from '@/cpin-module/infrastructure/database/prisma.service' + +export const projectSelect = { + id: true, + name: true, + slug: true, + description: true, + environments: { + select: { + id: true, + name: true, + clusterId: true, + cpu: true, + gpu: true, + memory: true, + autosync: true, + }, + }, +} satisfies Prisma.ProjectSelect + +export type ProjectWithDetails = Prisma.ProjectGetPayload<{ + select: typeof projectSelect +}> + +export const zoneSelect = { + id: true, + slug: true, + label: true, +} satisfies Prisma.ZoneSelect + +export type ZoneWithDetails = Prisma.ZoneGetPayload<{ + select: typeof zoneSelect +}> + +@Injectable() +export class VaultDatastoreService { + constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {} + + async getAllProjects(): Promise { + return this.prisma.project.findMany({ + select: projectSelect, + }) + } + + async getProject(id: string): Promise { + return this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }) + } + + async getAllZones(): Promise { + return this.prisma.zone.findMany({ + select: zoneSelect, + }) + } +} diff --git a/apps/server-nestjs/src/modules/vault/vault.service.ts b/apps/server-nestjs/src/modules/vault/vault.service.ts index 584c3c5eb..b4cefcf33 100644 --- a/apps/server-nestjs/src/modules/vault/vault.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault.service.ts @@ -3,6 +3,13 @@ import { ConfigurationService } from '@/cpin-module/infrastructure/configuration import { VaultClientService } from './vault-client.service' import type { VaultError, VaultResult, VaultSecret } from './vault-client.service' import { trace } from '@opentelemetry/api' +import { + generateAppAdminPolicyName, + generateTechnicalReadOnlyPolicyName, + generateZoneName, + generateZoneTechnicalReadOnlyPolicyName, +} from './vault.utils' +import type { ProjectWithDetails } from './vault-datastore.service' const tracer = trace.getTracer('vault-service') @@ -14,6 +21,28 @@ export class VaultService { ) { } + async read(path: string): Promise> { + return tracer.startActiveSpan('read', async (span) => { + try { + span.setAttribute('vault.path', path) + return await this.vaultClientService.read(path) + } finally { + span.end() + } + }) + } + + async write(data: any, path: string): Promise> { + return tracer.startActiveSpan('write', async (span) => { + try { + span.setAttribute('vault.path', path) + return await this.vaultClientService.write(data, path) + } finally { + span.end() + } + }) + } + async destroy(path: string): Promise> { return tracer.startActiveSpan('destroy', async (span) => { try { @@ -124,4 +153,244 @@ export class VaultService { } }) } + + private async upsertMount(kvName: string): Promise> { + const body = { + type: 'kv', + config: { + force_no_cache: true, + }, + options: { + version: 2, + }, + } + const created = await this.vaultClientService.createMount(kvName, body) + if (!created.error) return { data: undefined, error: null } + if (created.error.kind === 'HttpError' && created.error.status === 400) { + return await this.vaultClientService.updateMount(kvName, body) + } + return { data: null, error: created.error } + } + + private async deleteMount(kvName: string): Promise> { + return await this.vaultClientService.deleteMount(kvName) + } + + async upsertZone(zoneName: string): Promise> { + return tracer.startActiveSpan('upsertZone', async (span) => { + const kvName = generateZoneName(zoneName) + const policyName = generateZoneTechnicalReadOnlyPolicyName(zoneName) + const roleName = kvName + + try { + span.setAttribute('zone.name', zoneName) + span.setAttribute('vault.kvName', kvName) + + const mounted = await this.upsertMount(kvName) + if (mounted.error) return mounted + const policy = await this.vaultClientService.upsertPolicyAcl(policyName, { + policy: `path "${kvName}/*" { capabilities = ["read"] }`, + }) + if (policy.error) return { data: null, error: policy.error } + + const role = await this.vaultClientService.upsertRole(roleName, [policyName]) + if (role.error) return { data: null, error: role.error } + + return { data: undefined, error: null } + } finally { + span.end() + } + }) + } + + async deleteZone(zoneName: string): Promise> { + return tracer.startActiveSpan('deleteZone', async (span) => { + const kvName = generateZoneName(zoneName) + const policyName = generateZoneTechnicalReadOnlyPolicyName(zoneName) + const roleName = kvName + + try { + span.setAttribute('zone.name', zoneName) + span.setAttribute('vault.kvName', kvName) + await Promise.allSettled([ + this.deleteMount(kvName), + this.vaultClientService.deletePolicyAcl(policyName), + this.vaultClientService.deleteRole(roleName), + ]) + return { data: undefined, error: null } + } finally { + span.end() + } + }) + } + + async upsertProject(project: ProjectWithDetails): Promise> { + return tracer.startActiveSpan('upsertProject', async (span) => { + const kvName = project.slug + const appPolicyName = generateAppAdminPolicyName(project) + const techPolicyName = generateTechnicalReadOnlyPolicyName(project) + const roleName = project.slug + const groupName = project.slug + + try { + span.setAttribute('project.slug', project.slug) + span.setAttribute('vault.kvName', kvName) + + const mounted = await this.upsertMount(kvName) + if (mounted.error) return mounted + + const [appPolicy, techPolicy, group, role] = await Promise.all([ + this.createAppAdminPolicy(appPolicyName, project.slug), + this.createTechnicalReadOnlyPolicy(techPolicyName, project.slug), + this.ensureProjectGroup(groupName, appPolicyName), + this.vaultClientService.upsertRole(roleName, [techPolicyName, appPolicyName]), + ]) + + if (appPolicy.error) return { data: null, error: appPolicy.error } + if (techPolicy.error) return { data: null, error: techPolicy.error } + if (group.error) return { data: null, error: group.error } + if (role.error) return { data: null, error: role.error } + + return { data: undefined, error: null } + } finally { + span.end() + } + }) + } + + async deleteProject(projectSlug: string): Promise> { + return tracer.startActiveSpan('deleteProject', async (span) => { + const kvName = projectSlug + const appPolicyName = generateAppAdminPolicyName({ slug: projectSlug } as ProjectWithDetails) + const techPolicyName = generateTechnicalReadOnlyPolicyName({ slug: projectSlug } as ProjectWithDetails) + const roleName = projectSlug + const groupName = projectSlug + + try { + span.setAttribute('project.slug', projectSlug) + span.setAttribute('vault.kvName', kvName) + + const mountDeleted = await this.deleteMount(kvName) + if (mountDeleted.error?.kind === 'NotConfigured') return { data: null, error: mountDeleted.error } + + await Promise.allSettled([ + this.vaultClientService.deletePolicyAcl(appPolicyName), + this.vaultClientService.deletePolicyAcl(techPolicyName), + this.vaultClientService.deleteRole(roleName), + this.vaultClientService.deleteIdentityGroup(groupName), + ]) + return { data: undefined, error: null } + } finally { + span.end() + } + }) + } + + private async ensureProjectGroup(groupName: string, policyName: string): Promise> { + const upserted = await this.vaultClientService.upsertIdentityGroup(groupName, [policyName]) + if (upserted.error) return { data: null, error: upserted.error } + + const group = await this.vaultClientService.getIdentityGroup(groupName) + if (group.error) return { data: null, error: group.error } + if (!group.data?.data?.id) { + const error: VaultError = { + kind: 'InvalidResponse', + message: `Vault group not found after upsert: ${groupName}`, + method: 'GET', + path: `/v1/identity/group/name/${groupName}`, + } + return { data: null, error } + } + + const groupAliasName = `/${groupName}` + if (group.data.data.alias?.name === groupAliasName) return { data: undefined, error: null } + + const methods = await this.vaultClientService.getAuthMethods() + if (methods.error) return { data: null, error: methods.error } + const oidc = methods.data['oidc/'] + if (!oidc?.accessor) { + const error: VaultError = { + kind: 'InvalidResponse', + message: 'Vault OIDC auth method not found (expected "oidc/")', + method: 'GET', + path: '/v1/sys/auth', + } + return { data: null, error } + } + return await this.vaultClientService.createGroupAlias(groupAliasName, oidc.accessor, group.data.data.id) + } + + async createAppAdminPolicy(name: string, projectSlug: string): Promise> { + return await this.vaultClientService.upsertPolicyAcl( + name, + { + policy: `path "${projectSlug}/*" { capabilities = ["create", "read", "update", "delete", "list"] }`, + }, + ) + } + + async createTechnicalReadOnlyPolicy(name: string, projectSlug: string): Promise> { + const projectPath = this.config.projectRootPath + ? `${this.config.projectRootPath}/${projectSlug}` + : projectSlug + + return await this.vaultClientService.upsertPolicyAcl( + name, + { + policy: `path "${this.config.vaultKvName}/data/${projectPath}/REGISTRY/ro-robot" { capabilities = ["read"] }`, + }, + ) + } + + async listProjectSecrets(projectSlug: string): Promise> { + const projectPath = this.config.projectRootPath + ? `${this.config.projectRootPath}/${projectSlug}` + : projectSlug + return this.listRecursive(this.config.vaultKvName, projectPath, '') + } + + async destroyProjectSecrets(projectSlug: string): Promise> { + return tracer.startActiveSpan('destroyProjectSecrets', async (span) => { + try { + span.setAttribute('project.slug', projectSlug) + const secrets = await this.listProjectSecrets(projectSlug) + if (secrets.error) return { data: null, error: secrets.error } + + await Promise.allSettled(secrets.data.map(async (relativePath) => { + const fullPath = this.config.projectRootPath + ? `${this.config.projectRootPath}/${projectSlug}/${relativePath}` + : `${projectSlug}/${relativePath}` + const destroyed = await this.destroy(fullPath) + if (destroyed.error && destroyed.error.kind !== 'NotFound') return destroyed + })) + return { data: undefined, error: null } + } finally { + span.end() + } + }) + } + + private async listRecursive( + kvName: string, + basePath: string, + relativePath: string, + ): Promise> { + const combined = relativePath.length === 0 ? basePath : `${basePath}/${relativePath}` + const keys = await this.vaultClientService.listInKv(kvName, combined) + if (keys.error) return keys + if (keys.data.length === 0) return { data: [], error: null } + + const results: string[] = [] + for (const key of keys.data) { + if (key.endsWith('/')) { + const nestedRel = relativePath.length === 0 ? key.slice(0, -1) : `${relativePath}/${key.slice(0, -1)}` + const nested = await this.listRecursive(kvName, basePath, nestedRel) + if (nested.error) return nested + results.push(...nested.data) + } else { + results.push(relativePath.length === 0 ? key : `${relativePath}/${key}`) + } + } + return { data: results, error: null } + } } diff --git a/apps/server-nestjs/src/modules/vault/vault.utils.ts b/apps/server-nestjs/src/modules/vault/vault.utils.ts new file mode 100644 index 000000000..6dd011668 --- /dev/null +++ b/apps/server-nestjs/src/modules/vault/vault.utils.ts @@ -0,0 +1,17 @@ +import type { ProjectWithDetails } from './vault-datastore.service' + +export function generateTechnicalReadOnlyPolicyName(project: ProjectWithDetails) { + return `tech--${project.slug}--ro` +} + +export function generateAppAdminPolicyName(project: ProjectWithDetails) { + return `app--${project.slug}--admin` +} + +export function generateZoneName(name: string) { + return `zone-${name}` +} + +export function generateZoneTechnicalReadOnlyPolicyName(zoneName: string) { + return `tech--${generateZoneName(zoneName)}--ro` +}