From 7c86d37fdaf0a8375a8c13efc2043c142d2e5361 Mon Sep 17 00:00:00 2001 From: Marcel Menk Date: Sat, 20 Jun 2026 09:55:19 +0200 Subject: [PATCH] feat: dynamic module loading pattern --- .gitignore | 6 + SECURITY.md | 2 +- .../backend-agent-controller/Dockerfile.api | 9 +- .../backend-agent-controller/README.md | 17 ++ .../docker-compose.yaml | 12 ++ .../backend-agent-controller/project.json | 14 +- .../src/scripts/install-provider-plugins.ts | 10 + .../backend-agent-manager/Dockerfile.api | 9 +- apps/agenstra/backend-agent-manager/README.md | 11 + .../backend-agent-manager/docker-compose.yaml | 8 + .../backend-agent-manager/project.json | 17 +- .../src/scripts/install-provider-plugins.ts | 10 + apps/agenstra/frontend-docs/project.json | 5 + .../backend-billing-manager/Dockerfile.api | 7 +- .../docker-compose.yaml | 9 + .../backend-billing-manager/project.json | 14 +- .../src/scripts/install-provider-plugins.ts | 10 + docs/agenstra/README.md | 1 + docs/agenstra/api-reference/README.md | 4 +- .../applications/backend-agent-controller.md | 10 + .../applications/backend-agent-manager.md | 10 + docs/agenstra/deployment/README.md | 1 + docs/agenstra/deployment/background-jobs.md | 2 +- docs/agenstra/deployment/docker-deployment.md | 2 + .../deployment/environment-configuration.md | 23 +- docs/agenstra/features/README.md | 17 ++ docs/agenstra/features/agent-management.md | 5 +- docs/agenstra/features/atlassian-import.md | 3 +- .../features/billing-administration.md | 4 +- docs/agenstra/features/deployment.md | 2 + .../features/dynamic-provider-plugins.md | 188 +++++++++++++++++ docs/agenstra/features/server-provisioning.md | 7 +- .../features/websocket-communication.md | 2 +- docs/agenstra/security/accepted-risks.md | 34 +-- .../agenstra/security/ci-security-scanning.md | 18 +- .../security/operational-hardening.md | 2 +- .../vulnerability-reporting-and-artifacts.md | 4 +- .../feature-agent-controller/project.json | 5 +- .../src/lib/modules/clients.module.ts | 19 +- .../src/lib/modules/context-import.module.ts | 22 +- .../backend/feature-agent-manager/README.md | 9 + .../feature-agent-manager/project.json | 1 + .../src/lib/modules/agents.module.ts | 52 ++++- .../backend/feature-billing-manager/README.md | 14 +- .../feature-billing-manager/project.json | 3 +- .../src/lib/billing.module.ts | 38 +++- libs/domains/shared/backend/index.ts | 1 + .../.eslintrc.json | 18 ++ .../util-dynamic-provider-registry/README.md | 164 +++++++++++++++ .../jest.config.cts | 13 ++ .../package.json | 13 ++ .../project.json | 16 ++ .../src/index.ts | 12 ++ .../src/lib/assert-plugin-path.spec.ts | 15 ++ .../src/lib/assert-plugin-path.ts | 63 ++++++ .../src/lib/assert-runtime-dependency.spec.ts | 36 ++++ .../src/lib/assert-runtime-dependency.ts | 68 ++++++ .../dynamic-provider-loader.service.spec.ts | 119 +++++++++++ .../lib/dynamic-provider-loader.service.ts | 132 ++++++++++++ .../src/lib/install-provider-plugins.spec.ts | 100 +++++++++ .../src/lib/install-provider-plugins.ts | 127 ++++++++++++ .../src/lib/load-provider-module.ts | 104 ++++++++++ .../lib/parse-provider-package-spec.spec.ts | 64 ++++++ .../src/lib/parse-provider-package-spec.ts | 82 ++++++++ .../src/lib/plugin-path-index.spec.ts | 20 ++ .../src/lib/plugin-path-index.ts | 196 ++++++++++++++++++ .../lib/register-dynamic-providers.spec.ts | 37 ++++ .../src/lib/register-dynamic-providers.ts | 41 ++++ .../src/lib/resolve-provider-export.spec.ts | 90 ++++++++ .../src/lib/resolve-provider-export.ts | 100 +++++++++ .../lib/resolve-provider-load-target.spec.ts | 44 ++++ .../src/lib/resolve-provider-load-target.ts | 76 +++++++ .../src/lib/startup-error-policy.spec.ts | 56 +++++ .../src/lib/startup-error-policy.ts | 49 +++++ .../src/lib/types.ts | 113 ++++++++++ .../app-root-fixture/package.json | 8 + .../src/test-fixtures/global-setup.cjs | 3 + .../mounted-plugin-fixture/index.js | 20 ++ .../mounted-plugin-fixture/package.json | 6 + .../mounted-plugin-fixture/index.js | 20 ++ .../mounted-plugin-fixture/package.json | 6 + .../runtime-provider-fixture/index.js | 20 ++ .../runtime-provider-fixture/package.json | 6 + .../test-fixtures/setup-runtime-fixture.cjs | 32 +++ .../tsconfig.json | 16 ++ .../tsconfig.lib.json | 15 ++ .../tsconfig.spec.json | 16 ++ tools/ci/verify-install-provider-plugins.sh | 20 ++ tsconfig.base.json | 3 + 89 files changed, 2764 insertions(+), 68 deletions(-) create mode 100644 apps/agenstra/backend-agent-controller/src/scripts/install-provider-plugins.ts create mode 100644 apps/agenstra/backend-agent-manager/src/scripts/install-provider-plugins.ts create mode 100644 apps/decabill/backend-billing-manager/src/scripts/install-provider-plugins.ts create mode 100644 docs/agenstra/features/dynamic-provider-plugins.md create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/.eslintrc.json create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/README.md create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/jest.config.cts create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/package.json create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/project.json create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/index.ts create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/assert-plugin-path.spec.ts create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/assert-plugin-path.ts create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/assert-runtime-dependency.spec.ts create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/assert-runtime-dependency.ts create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/dynamic-provider-loader.service.spec.ts create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/dynamic-provider-loader.service.ts create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/install-provider-plugins.spec.ts create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/install-provider-plugins.ts create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/load-provider-module.ts create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/parse-provider-package-spec.spec.ts create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/parse-provider-package-spec.ts create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/plugin-path-index.spec.ts create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/plugin-path-index.ts create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/register-dynamic-providers.spec.ts create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/register-dynamic-providers.ts create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/resolve-provider-export.spec.ts create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/resolve-provider-export.ts create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/resolve-provider-load-target.spec.ts create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/resolve-provider-load-target.ts create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/startup-error-policy.spec.ts create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/startup-error-policy.ts create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/types.ts create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/app-root-fixture/package.json create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/global-setup.cjs create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/mounted-plugin-fixture/index.js create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/mounted-plugin-fixture/package.json create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/plugin-root/mounted-plugin-fixture/index.js create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/plugin-root/mounted-plugin-fixture/package.json create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/runtime-provider-fixture/index.js create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/runtime-provider-fixture/package.json create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/setup-runtime-fixture.cjs create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/tsconfig.json create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/tsconfig.lib.json create mode 100644 libs/domains/shared/backend/util-dynamic-provider-registry/tsconfig.spec.json create mode 100755 tools/ci/verify-install-provider-plugins.sh diff --git a/.gitignore b/.gitignore index 14b7efe3c..4e8c60fb4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,12 @@ tmp-generated generated out-tsc +# Stray tsc output next to TypeScript library sources +libs/domains/shared/backend/util-dynamic-provider-registry/src/index.js +libs/domains/shared/backend/util-dynamic-provider-registry/src/index.js.map +libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/*.js +libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/*.js.map + # dependencies node_modules diff --git a/SECURITY.md b/SECURITY.md index d2bb1c61f..65f5b1d70 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -96,7 +96,7 @@ The product intentionally departs from stricter baselines in a few places. Each | ID | Area | What we accept | Mitigations (short) | Next review | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | -| **AR-001** | **Provisioning SSH** (cloud-init templates) | **`PermitRootLogin yes`** and **root** `authorized_keys` installed via provisioning scripts (`libs/domains/agenstra/backend/feature-billing-manager/.../agent-controller.utils.ts`, `agent-manager.utils.ts`) | SSH **key-based** access; **password authentication disabled** in generated `sshd` config; deployers should restrict network access, rotate keys, and monitor instances | **2027-05-06**, or sooner if cloud-init/SSH templates change materially | +| **AR-001** | **Provisioning SSH** (cloud-init templates) | **`PermitRootLogin yes`** and **root** `authorized_keys` installed via provisioning scripts (`libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/cloud-init/agent-controller.utils.ts`, `agent-manager.utils.ts`) | SSH **key-based** access; **password authentication disabled** in generated `sshd` config; deployers should restrict network access, rotate keys, and monitor instances | **2027-05-06**, or sooner if cloud-init/SSH templates change materially | | **AR-002** | **Desktop app** (`agenstra-native-agent-console`) | **No OS-trusted code signing** and **no in-app auto-update** in the Electron Forge pipeline (`apps/agenstra/native-agent-console/forge.config.js`) | Release artifacts include **`SHA256SUMS`** and **`integrity-manifest.json`** produced by [`tools/release-integrity`](./tools/release-integrity/README.md); CI/release pipelines **generate and verify** these manifests. Users should verify checksums after download. The web browser remains the primary client; the native build is a secondary channel. | **2027-05-06**, or sooner if desktop becomes the primary distribution path | | **AR-003** | **Web frontends** (`frontend-*`) | **Content Security Policy** allows **`'unsafe-inline'`** and **`'unsafe-eval'`** so **Monaco Editor** and related tooling work; policy is sent as **`Content-Security-Policy-Report-Only`** by default (violations are reported, not blocked) | Set **`CSP_ENFORCE=true`** only in environments where compatibility is validated. Implementation: `libs/domains/shared/frontend/util-express-server/src/lib/security-headers.ts`. Hardening path: stricter CSP with a validated Monaco/worker/nonce strategy. | **2027-05-06**, or sooner if CSP middleware changes materially | | **AR-004** | **Backend authentication mode resolution** (`getAuthenticationMethod` in `libs/domains/identity/backend/util-auth/src/lib/hybrid-auth.guard.ts`) | We do **not** require **`AUTHENTICATION_METHOD`** to always be set. When it is unset: if **`STATIC_API_KEY`** is set → **api-key** mode; otherwise → **keycloak** (OIDC / **Keycloak** integration with the deployer’s IdP). **Protected routes are not anonymous**—Keycloak- or users-mode guards still enforce authentication per configuration. | **Default `keycloak`** favors the most integrated, enterprise-typical option (customer IdP). For **api-key** or **users** deployments, set **`AUTHENTICATION_METHOD`** explicitly and treat **`STATIC_API_KEY`** as a high-value secret (rotation, least exposure). | **2027-05-06**, or sooner if hybrid auth resolution changes materially | diff --git a/apps/agenstra/backend-agent-controller/Dockerfile.api b/apps/agenstra/backend-agent-controller/Dockerfile.api index 7693d5104..c4b5154c4 100644 --- a/apps/agenstra/backend-agent-controller/Dockerfile.api +++ b/apps/agenstra/backend-agent-controller/Dockerfile.api @@ -68,6 +68,9 @@ RUN . "${NVM_DIR}/nvm.sh" && npm i && npm cache clean --force && \ COPY --chown=agenstra:agenstra . /app +RUN mkdir -p /var/lib/forepath/provider-plugins && \ + chown agenstra:agenstra /var/lib/forepath/provider-plugins + RUN printf '%s\n' \ '#!/bin/bash' \ 'set -euo pipefail' \ @@ -79,7 +82,11 @@ RUN printf '%s\n' \ ' sudo groupadd -g "${docker_sock_gid}" docker' \ ' fi' \ ' sudo usermod -aG docker agenstra' \ - ' exec sg docker -c '"'"'set -euo pipefail; . "${NVM_DIR}/nvm.sh"; exec node main.js'"'"'' \ + ' exec sg docker -c '"'"'set -euo pipefail; . "${NVM_DIR}/nvm.sh"; if [ -n "${DYNAMIC_PROVIDER_PLUGIN_PATH:-}" ]; then node install-provider-plugins.js; fi; exec node main.js'"'"'' \ + 'fi' \ + 'if [ -n "${DYNAMIC_PROVIDER_PLUGIN_PATH:-}" ]; then' \ + ' . "${NVM_DIR}/nvm.sh"' \ + ' node install-provider-plugins.js' \ 'fi' \ '. "${NVM_DIR}/nvm.sh"' \ 'exec node main.js' \ diff --git a/apps/agenstra/backend-agent-controller/README.md b/apps/agenstra/backend-agent-controller/README.md index 6f26fd3c6..acecdd9d3 100644 --- a/apps/agenstra/backend-agent-controller/README.md +++ b/apps/agenstra/backend-agent-controller/README.md @@ -243,6 +243,23 @@ See the [library documentation](../../libs/domains/agenstra/backend/feature-agen - `DIGITALOCEAN_API_TOKEN` - DigitalOcean API token (for server provisioning) - `ENCRYPTION_KEY` - Encryption key for sensitive data +**Dynamic provider plugins (optional):** + +- `DYNAMIC_PROVISIONING_PROVIDERS` - Comma-separated extra provisioning packages (`alias=@forepath/pkg` or `alias=file:dir`) +- `DYNAMIC_CONTEXT_IMPORT_PROVIDERS` - Comma-separated extra context import provider packages +- `DYNAMIC_PROVIDERS_FAIL_FAST` - When `true`, abort startup if critical dynamic providers fail to load (recommended in production when `DYNAMIC_PROVISIONING_PROVIDERS` is set) +- `DYNAMIC_PROVIDER_PLUGIN_PATH` - Absolute plugin root for post-build loading (unset by default; set to `/var/lib/forepath/provider-plugins` when using the compose volume) +- `DYNAMIC_PROVIDER_PLUGIN_INSTALL` - Comma-separated packages/tarballs to `npm install` into the plugin path at container startup + +See `@forepath/shared/backend/util-dynamic-provider-registry` README for plugin export contract, baked-in vs mounted loading, and the post-build operator workflow. + +### Post-build plugins (no image rebuild) + +1. Build the plugin package (`nx build `) to produce compiled JS and `package.json` +2. Copy the plugin into `./provider-plugins/` on the host (mounted read-only in compose) and/or set `DYNAMIC_PROVIDER_PLUGIN_INSTALL` +3. Set `DYNAMIC_PROVISIONING_PROVIDERS` (or other `DYNAMIC_*` vars) to reference the package name +4. Restart the container + ## Docker Deployment The application includes Dockerfiles for containerized deployment: diff --git a/apps/agenstra/backend-agent-controller/docker-compose.yaml b/apps/agenstra/backend-agent-controller/docker-compose.yaml index 441c4d051..1c592ca3b 100644 --- a/apps/agenstra/backend-agent-controller/docker-compose.yaml +++ b/apps/agenstra/backend-agent-controller/docker-compose.yaml @@ -36,6 +36,12 @@ x-backend-agent-controller-environment: &backend-agent-controller-environment QUEUE_BULL_BOARD_PASSWORD: ${QUEUE_BULL_BOARD_PASSWORD:-bullmq} HETZNER_API_TOKEN: ${HETZNER_API_TOKEN:-} DIGITALOCEAN_API_TOKEN: ${DIGITALOCEAN_API_TOKEN:-} + # Dynamic provider plugins (baked-in runtime deps and/or mounted plugin path) + DYNAMIC_PROVISIONING_PROVIDERS: ${DYNAMIC_PROVISIONING_PROVIDERS:-} + DYNAMIC_CONTEXT_IMPORT_PROVIDERS: ${DYNAMIC_CONTEXT_IMPORT_PROVIDERS:-} + DYNAMIC_PROVIDERS_FAIL_FAST: ${DYNAMIC_PROVIDERS_FAIL_FAST:-false} + DYNAMIC_PROVIDER_PLUGIN_PATH: ${DYNAMIC_PROVIDER_PLUGIN_PATH:-} + DYNAMIC_PROVIDER_PLUGIN_INSTALL: ${DYNAMIC_PROVIDER_PLUGIN_INSTALL:-} AUTHENTICATION_METHOD: ${AUTHENTICATION_METHOD:-api-key} DISABLE_SIGNUP: ${DISABLE_SIGNUP:-false} STATIC_API_KEY: ${STATIC_API_KEY:-} @@ -108,6 +114,8 @@ services: ports: - '${PORT:-3100}:${PORT:-3100}' - '${WEBSOCKET_PORT:-8081}:${WEBSOCKET_PORT:-8081}' + volumes: + - ./provider-plugins:/var/lib/forepath/provider-plugins depends_on: postgres: condition: service_healthy @@ -127,6 +135,8 @@ services: <<: *backend-agent-controller-environment QUEUE_ROLE: worker QUEUE_BULL_BOARD_ENABLED: false + volumes: + - ./provider-plugins:/var/lib/forepath/provider-plugins depends_on: backend-agent-controller: condition: service_healthy @@ -148,6 +158,8 @@ services: <<: *backend-agent-controller-environment QUEUE_ROLE: scheduler QUEUE_BULL_BOARD_ENABLED: false + volumes: + - ./provider-plugins:/var/lib/forepath/provider-plugins depends_on: backend-agent-controller: condition: service_healthy diff --git a/apps/agenstra/backend-agent-controller/project.json b/apps/agenstra/backend-agent-controller/project.json index 93ed85d4d..24048e497 100644 --- a/apps/agenstra/backend-agent-controller/project.json +++ b/apps/agenstra/backend-agent-controller/project.json @@ -64,15 +64,25 @@ "commands": [ "mkdir -p dist/apps/agenstra/backend-agent-controller/src/migrations", "(npx tsc $(find apps/agenstra/backend-agent-controller/src/migrations -name '*.ts' 2>/dev/null || echo '') --outDir dist/apps/agenstra/backend-agent-controller/src/migrations --module commonjs --target es2021 --moduleResolution node --esModuleInterop --skipLibCheck --resolveJsonModule --declaration false --rootDir apps/agenstra/backend-agent-controller/src/migrations 2>/dev/null || true)", - "npx tsc $(find libs/domains/identity/backend/util-auth/src/lib/migrations -name '*.ts') --outDir dist/apps/agenstra/backend-agent-controller/src/migrations --module commonjs --target es2021 --moduleResolution node --esModuleInterop --skipLibCheck --resolveJsonModule --declaration false --rootDir libs/domains/identity/backend/util-auth/src/lib/migrations" + "npx tsc $(find libs/domains/identity/backend/util-auth/src/lib/migrations -name '*.ts') --outDir dist/apps/agenstra/backend-agent-controller/src/migrations --module commonjs --target es2021 --moduleResolution node --esModuleInterop --skipLibCheck --resolveJsonModule --declaration false --rootDir libs/domains/identity/backend/util-auth/src/lib/migrations", + "npx esbuild apps/agenstra/backend-agent-controller/src/scripts/install-provider-plugins.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/apps/agenstra/backend-agent-controller/install-provider-plugins.js --tsconfig=tsconfig.base.json --packages=external" ] } }, + "verify-install-provider-plugins": { + "dependsOn": ["compile-migrations"], + "executor": "nx:run-commands", + "options": { + "command": "bash tools/ci/verify-install-provider-plugins.sh dist/apps/agenstra/backend-agent-controller/install-provider-plugins.js", + "cwd": "{workspaceRoot}" + } + }, "prune": { "dependsOn": [ "prune-lockfile", "copy-workspace-modules", - "compile-migrations" + "compile-migrations", + "verify-install-provider-plugins" ], "executor": "nx:noop" }, diff --git a/apps/agenstra/backend-agent-controller/src/scripts/install-provider-plugins.ts b/apps/agenstra/backend-agent-controller/src/scripts/install-provider-plugins.ts new file mode 100644 index 000000000..2e7ead193 --- /dev/null +++ b/apps/agenstra/backend-agent-controller/src/scripts/install-provider-plugins.ts @@ -0,0 +1,10 @@ +import { installProviderPluginsFromEnv } from '@forepath/shared/backend/util-dynamic-provider-registry'; + +async function main(): Promise { + await installProviderPluginsFromEnv(); +} + +main().catch((error: unknown) => { + console.error(error); + process.exit(1); +}); diff --git a/apps/agenstra/backend-agent-manager/Dockerfile.api b/apps/agenstra/backend-agent-manager/Dockerfile.api index df51d6f84..26bb20f41 100644 --- a/apps/agenstra/backend-agent-manager/Dockerfile.api +++ b/apps/agenstra/backend-agent-manager/Dockerfile.api @@ -68,6 +68,9 @@ RUN . "${NVM_DIR}/nvm.sh" && npm i && npm cache clean --force && \ COPY --chown=agenstra:agenstra . /app +RUN mkdir -p /var/lib/forepath/provider-plugins && \ + chown agenstra:agenstra /var/lib/forepath/provider-plugins + RUN printf '%s\n' \ '#!/bin/bash' \ 'set -euo pipefail' \ @@ -79,7 +82,11 @@ RUN printf '%s\n' \ ' sudo groupadd -g "${docker_sock_gid}" docker' \ ' fi' \ ' sudo usermod -aG docker agenstra' \ - ' exec sg docker -c '"'"'set -euo pipefail; . "${NVM_DIR}/nvm.sh"; exec node main.js'"'"'' \ + ' exec sg docker -c '"'"'set -euo pipefail; . "${NVM_DIR}/nvm.sh"; if [ -n "${DYNAMIC_PROVIDER_PLUGIN_PATH:-}" ]; then node install-provider-plugins.js; fi; exec node main.js'"'"'' \ + 'fi' \ + 'if [ -n "${DYNAMIC_PROVIDER_PLUGIN_PATH:-}" ]; then' \ + ' . "${NVM_DIR}/nvm.sh"' \ + ' node install-provider-plugins.js' \ 'fi' \ '. "${NVM_DIR}/nvm.sh"' \ 'exec node main.js' \ diff --git a/apps/agenstra/backend-agent-manager/README.md b/apps/agenstra/backend-agent-manager/README.md index 5c8603e74..e34c1e82b 100644 --- a/apps/agenstra/backend-agent-manager/README.md +++ b/apps/agenstra/backend-agent-manager/README.md @@ -217,6 +217,17 @@ See the [library documentation](../../libs/domains/agenstra/backend/feature-agen - `RATE_LIMIT_TTL` - Time window in seconds (default: `60`) - `RATE_LIMIT_LIMIT` - Maximum requests per window (default: `100`) +**Dynamic provider plugins (optional):** + +- `DYNAMIC_AGENT_PROVIDERS` - Comma-separated extra agent backend packages +- `DYNAMIC_PIPELINE_PROVIDERS` - Comma-separated extra CI/CD provider packages +- `DYNAMIC_CHAT_FILTERS` - Comma-separated extra chat filter packages +- `DYNAMIC_PROVIDERS_FAIL_FAST` - When `true`, abort startup if critical dynamic providers fail to load +- `DYNAMIC_PROVIDER_PLUGIN_PATH` - Plugin root for post-build loading (unset by default; set to `/var/lib/forepath/provider-plugins` when using the compose volume) +- `DYNAMIC_PROVIDER_PLUGIN_INSTALL` - Startup `npm install` targets into the plugin path + +See `@forepath/shared/backend/util-dynamic-provider-registry` README for export contract and post-build mount workflow. Mount `./provider-plugins` (see `docker-compose.yaml`) to add providers without rebuilding the image. + ## Docker Deployment The application includes Dockerfiles for containerized deployment: diff --git a/apps/agenstra/backend-agent-manager/docker-compose.yaml b/apps/agenstra/backend-agent-manager/docker-compose.yaml index 0b7efbe8f..985f82ffa 100644 --- a/apps/agenstra/backend-agent-manager/docker-compose.yaml +++ b/apps/agenstra/backend-agent-manager/docker-compose.yaml @@ -32,6 +32,13 @@ services: # Cursor agent configuration CURSOR_API_KEY: ${CURSOR_API_KEY:-} AGENT_DEFAULT_IMAGE: ${AGENT_DEFAULT_IMAGE:-ghcr.io/forepath/agenstra-manager-worker:latest} + # Dynamic provider plugins (baked-in runtime deps and/or mounted plugin path) + DYNAMIC_AGENT_PROVIDERS: ${DYNAMIC_AGENT_PROVIDERS:-} + DYNAMIC_PIPELINE_PROVIDERS: ${DYNAMIC_PIPELINE_PROVIDERS:-} + DYNAMIC_CHAT_FILTERS: ${DYNAMIC_CHAT_FILTERS:-} + DYNAMIC_PROVIDERS_FAIL_FAST: ${DYNAMIC_PROVIDERS_FAIL_FAST:-false} + DYNAMIC_PROVIDER_PLUGIN_PATH: ${DYNAMIC_PROVIDER_PLUGIN_PATH:-} + DYNAMIC_PROVIDER_PLUGIN_INSTALL: ${DYNAMIC_PROVIDER_PLUGIN_INSTALL:-} # Database configuration DB_HOST: ${DB_HOST:-postgres} DB_PORT: ${DB_PORT:-5432} @@ -79,6 +86,7 @@ services: volumes: # Mount Docker socket for Docker-in-Docker functionality - /var/run/docker.sock:/var/run/docker.sock + - ./provider-plugins:/var/lib/forepath/provider-plugins depends_on: postgres: condition: service_healthy diff --git a/apps/agenstra/backend-agent-manager/project.json b/apps/agenstra/backend-agent-manager/project.json index 109f85223..305a5883e 100644 --- a/apps/agenstra/backend-agent-manager/project.json +++ b/apps/agenstra/backend-agent-manager/project.json @@ -60,14 +60,27 @@ "dependsOn": ["build"], "executor": "nx:run-commands", "options": { - "command": "mkdir -p dist/apps/agenstra/backend-agent-manager/src/migrations && npx tsc $(find apps/agenstra/backend-agent-manager/src/migrations -name '*.ts') --outDir dist/apps/agenstra/backend-agent-manager/src/migrations --module commonjs --target es2021 --moduleResolution node --esModuleInterop --skipLibCheck --resolveJsonModule --declaration false --rootDir apps/agenstra/backend-agent-manager/src/migrations" + "commands": [ + "mkdir -p dist/apps/agenstra/backend-agent-manager/src/migrations", + "npx tsc $(find apps/agenstra/backend-agent-manager/src/migrations -name '*.ts') --outDir dist/apps/agenstra/backend-agent-manager/src/migrations --module commonjs --target es2021 --moduleResolution node --esModuleInterop --skipLibCheck --resolveJsonModule --declaration false --rootDir apps/agenstra/backend-agent-manager/src/migrations", + "npx esbuild apps/agenstra/backend-agent-manager/src/scripts/install-provider-plugins.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/apps/agenstra/backend-agent-manager/install-provider-plugins.js --tsconfig=tsconfig.base.json --packages=external" + ] + } + }, + "verify-install-provider-plugins": { + "dependsOn": ["compile-migrations"], + "executor": "nx:run-commands", + "options": { + "command": "bash tools/ci/verify-install-provider-plugins.sh dist/apps/agenstra/backend-agent-manager/install-provider-plugins.js", + "cwd": "{workspaceRoot}" } }, "prune": { "dependsOn": [ "prune-lockfile", "copy-workspace-modules", - "compile-migrations" + "compile-migrations", + "verify-install-provider-plugins" ], "executor": "nx:noop" }, diff --git a/apps/agenstra/backend-agent-manager/src/scripts/install-provider-plugins.ts b/apps/agenstra/backend-agent-manager/src/scripts/install-provider-plugins.ts new file mode 100644 index 000000000..2e7ead193 --- /dev/null +++ b/apps/agenstra/backend-agent-manager/src/scripts/install-provider-plugins.ts @@ -0,0 +1,10 @@ +import { installProviderPluginsFromEnv } from '@forepath/shared/backend/util-dynamic-provider-registry'; + +async function main(): Promise { + await installProviderPluginsFromEnv(); +} + +main().catch((error: unknown) => { + console.error(error); + process.exit(1); +}); diff --git a/apps/agenstra/frontend-docs/project.json b/apps/agenstra/frontend-docs/project.json index e0d556302..4e60f5672 100644 --- a/apps/agenstra/frontend-docs/project.json +++ b/apps/agenstra/frontend-docs/project.json @@ -62,6 +62,11 @@ "glob": "**/*.yaml", "input": "libs/domains/agenstra/backend/feature-agent-manager/spec", "output": "spec/agent-manager" + }, + { + "glob": "**/*.yaml", + "input": "libs/domains/decabill/backend/feature-billing-manager/spec", + "output": "spec/billing-manager" } ], "styles": [ diff --git a/apps/decabill/backend-billing-manager/Dockerfile.api b/apps/decabill/backend-billing-manager/Dockerfile.api index abd755b63..af12bdc97 100644 --- a/apps/decabill/backend-billing-manager/Dockerfile.api +++ b/apps/decabill/backend-billing-manager/Dockerfile.api @@ -59,8 +59,8 @@ RUN . "${NVM_DIR}/nvm.sh" && \ COPY --chown=agenstra:agenstra . /app -RUN mkdir -p /data/invoices && \ - chown -R agenstra:agenstra /data +RUN mkdir -p /data/invoices /var/lib/forepath/provider-plugins && \ + chown -R agenstra:agenstra /data /var/lib/forepath/provider-plugins RUN printf '%s\n' \ '#!/bin/bash' \ @@ -69,6 +69,9 @@ RUN printf '%s\n' \ 'mkdir -p "${storage_path}"' \ 'chown -R agenstra:agenstra /data "${storage_path}"' \ '. "${NVM_DIR}/nvm.sh"' \ + 'if [ -n "${DYNAMIC_PROVIDER_PLUGIN_PATH:-}" ]; then' \ + ' runuser -u agenstra -g agenstra -- node install-provider-plugins.js' \ + 'fi' \ 'exec runuser -u agenstra -g agenstra -- node main.js' \ > /usr/local/bin/docker-entrypoint.sh && \ chmod 755 /usr/local/bin/docker-entrypoint.sh diff --git a/apps/decabill/backend-billing-manager/docker-compose.yaml b/apps/decabill/backend-billing-manager/docker-compose.yaml index 75c8d07ef..26ac4ed93 100644 --- a/apps/decabill/backend-billing-manager/docker-compose.yaml +++ b/apps/decabill/backend-billing-manager/docker-compose.yaml @@ -61,6 +61,12 @@ x-backend-billing-manager-environment: &backend-billing-manager-environment STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-http://localhost:4500/invoices?payment=success} STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-http://localhost:4500/invoices?payment=cancel} + # Dynamic provider plugins (baked-in runtime deps and/or mounted plugin path) + DYNAMIC_PAYMENT_PROCESSORS: ${DYNAMIC_PAYMENT_PROCESSORS:-} + DYNAMIC_BILLING_PROVIDER_METADATA: ${DYNAMIC_BILLING_PROVIDER_METADATA:-} + DYNAMIC_PROVIDERS_FAIL_FAST: ${DYNAMIC_PROVIDERS_FAIL_FAST:-false} + DYNAMIC_PROVIDER_PLUGIN_PATH: ${DYNAMIC_PROVIDER_PLUGIN_PATH:-} + DYNAMIC_PROVIDER_PLUGIN_INSTALL: ${DYNAMIC_PROVIDER_PLUGIN_INSTALL:-} BILLING_FRONTEND_URL: ${BILLING_FRONTEND_URL:-http://localhost:4500} TENANT_FRONTEND_URLS: ${TENANT_FRONTEND_URLS:-} BILLING_SCHEDULER_INTERVAL: ${BILLING_SCHEDULER_INTERVAL:-60000} @@ -128,6 +134,7 @@ services: QUEUE_BULL_BOARD_ENABLED: ${QUEUE_BULL_BOARD_ENABLED:-true} volumes: - invoice_pdf_data:/data/invoices + - ./provider-plugins:/var/lib/forepath/provider-plugins ports: - '${PORT:-3200}:${PORT:-3200}' - '${WEBSOCKET_PORT:-8082}:${WEBSOCKET_PORT:-8082}' @@ -152,6 +159,7 @@ services: QUEUE_BULL_BOARD_ENABLED: false volumes: - invoice_pdf_data:/data/invoices + - ./provider-plugins:/var/lib/forepath/provider-plugins depends_on: backend-billing-manager: condition: service_healthy @@ -175,6 +183,7 @@ services: QUEUE_BULL_BOARD_ENABLED: false volumes: - invoice_pdf_data:/data/invoices + - ./provider-plugins:/var/lib/forepath/provider-plugins depends_on: backend-billing-manager: condition: service_healthy diff --git a/apps/decabill/backend-billing-manager/project.json b/apps/decabill/backend-billing-manager/project.json index 181b9288d..fa70afe49 100644 --- a/apps/decabill/backend-billing-manager/project.json +++ b/apps/decabill/backend-billing-manager/project.json @@ -68,15 +68,25 @@ "commands": [ "mkdir -p dist/apps/decabill/backend-billing-manager/src/migrations", "(npx tsc $(find apps/decabill/backend-billing-manager/src/migrations -name '*.ts' 2>/dev/null || echo '') --outDir dist/apps/decabill/backend-billing-manager/src/migrations --module commonjs --target es2021 --moduleResolution node --esModuleInterop --skipLibCheck --resolveJsonModule --declaration false --rootDir apps/decabill/backend-billing-manager/src/migrations 2>/dev/null || true)", - "npx tsc $(find libs/domains/identity/backend/util-auth/src/lib/migrations -name '*.ts') --outDir dist/apps/decabill/backend-billing-manager/src/migrations --module commonjs --target es2021 --moduleResolution node --esModuleInterop --skipLibCheck --resolveJsonModule --declaration false --rootDir libs/domains/identity/backend/util-auth/src/lib/migrations" + "npx tsc $(find libs/domains/identity/backend/util-auth/src/lib/migrations -name '*.ts') --outDir dist/apps/decabill/backend-billing-manager/src/migrations --module commonjs --target es2021 --moduleResolution node --esModuleInterop --skipLibCheck --resolveJsonModule --declaration false --rootDir libs/domains/identity/backend/util-auth/src/lib/migrations", + "npx esbuild apps/decabill/backend-billing-manager/src/scripts/install-provider-plugins.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/apps/decabill/backend-billing-manager/install-provider-plugins.js --tsconfig=tsconfig.base.json --packages=external" ] } }, + "verify-install-provider-plugins": { + "dependsOn": ["compile-migrations"], + "executor": "nx:run-commands", + "options": { + "command": "bash tools/ci/verify-install-provider-plugins.sh dist/apps/decabill/backend-billing-manager/install-provider-plugins.js", + "cwd": "{workspaceRoot}" + } + }, "prune": { "dependsOn": [ "prune-lockfile", "copy-workspace-modules", - "compile-migrations" + "compile-migrations", + "verify-install-provider-plugins" ], "executor": "nx:noop" }, diff --git a/apps/decabill/backend-billing-manager/src/scripts/install-provider-plugins.ts b/apps/decabill/backend-billing-manager/src/scripts/install-provider-plugins.ts new file mode 100644 index 000000000..2e7ead193 --- /dev/null +++ b/apps/decabill/backend-billing-manager/src/scripts/install-provider-plugins.ts @@ -0,0 +1,10 @@ +import { installProviderPluginsFromEnv } from '@forepath/shared/backend/util-dynamic-provider-registry'; + +async function main(): Promise { + await installProviderPluginsFromEnv(); +} + +main().catch((error: unknown) => { + console.error(error); + process.exit(1); +}); diff --git a/docs/agenstra/README.md b/docs/agenstra/README.md index ac17d9f17..cd7e75793 100644 --- a/docs/agenstra/README.md +++ b/docs/agenstra/README.md @@ -68,6 +68,7 @@ Comprehensive feature documentation: - [VNC Browser Access](./features/vnc-browser-access.md) - Graphical browser access via VNC and noVNC - [Authentication](./features/authentication.md) - Multiple authentication methods with configurable user registration - [Atlassian import](./features/atlassian-import.md) - Jira and Confluence imports into controller tickets and knowledge (admin) +- [Dynamic provider plugins](./features/dynamic-provider-plugins.md) - Runtime provider extensions for controller and manager (baked-in or mounted) ### [Deployment](./deployment/README.md) diff --git a/docs/agenstra/api-reference/README.md b/docs/agenstra/api-reference/README.md index c9311e205..db4300d39 100644 --- a/docs/agenstra/api-reference/README.md +++ b/docs/agenstra/api-reference/README.md @@ -77,7 +77,7 @@ The Agent Manager WebSocket gateway provides: ## Billing Manager HTTP API -**OpenAPI Specification**: [openapi.yaml](/spec/billing-manager/openapi.yaml) (canonical source: `libs/domains/agenstra/backend/feature-billing-manager/spec/openapi.yaml`) +**OpenAPI Specification**: [openapi.yaml](/spec/billing-manager/openapi.yaml) (canonical source: `libs/domains/decabill/backend/feature-billing-manager/spec/openapi.yaml`) Admin manual invoices and customer billing profile CRUD: [Billing Administration](../features/billing-administration.md) @@ -88,7 +88,7 @@ The billing manager exposes a Socket.IO gateway for **dashboard server status** **AsyncAPI Specification**: [asyncapi.yaml](/spec/billing-manager/asyncapi.yaml) - **View in AsyncAPI Studio**: [Open in AsyncAPI Studio](https://studio.asyncapi.com/?url=https://docs.agenstra.com/spec/billing-manager/asyncapi.yaml) -- **Download**: [asyncapi.yaml](/spec/billing-manager/asyncapi.yaml) (canonical source in-repo: `libs/domains/agenstra/backend/feature-billing-manager/spec/asyncapi.yaml`) +- **Download**: [asyncapi.yaml](/spec/billing-manager/asyncapi.yaml) (canonical source in-repo: `libs/domains/decabill/backend/feature-billing-manager/spec/asyncapi.yaml`) The billing status gateway provides: diff --git a/docs/agenstra/applications/backend-agent-controller.md b/docs/agenstra/applications/backend-agent-controller.md index cf9ab7c7e..e070f0399 100644 --- a/docs/agenstra/applications/backend-agent-controller.md +++ b/docs/agenstra/applications/backend-agent-controller.md @@ -376,6 +376,15 @@ See the application docs and environment configuration for complete environment - `DIGITALOCEAN_API_TOKEN` - DigitalOcean API token - `ENCRYPTION_KEY` - Encryption key for sensitive data +**Dynamic provider plugins (optional):** + +- `DYNAMIC_PROVISIONING_PROVIDERS` - Extra provisioning packages (critical registry) +- `DYNAMIC_CONTEXT_IMPORT_PROVIDERS` - Extra context import provider packages +- `DYNAMIC_PROVIDERS_FAIL_FAST` - Abort startup on critical provider load errors when `true` +- `DYNAMIC_PROVIDER_PLUGIN_PATH` / `DYNAMIC_PROVIDER_PLUGIN_INSTALL` - Post-build plugin mount and startup install + +See [Dynamic provider plugins](../features/dynamic-provider-plugins.md) and [Environment configuration](../deployment/environment-configuration.md). + ## Database Setup The application uses TypeORM and requires a database connection to be configured. See the application docs for database setup requirements and entity schema. @@ -429,6 +438,7 @@ Before deploying to production, ensure: - **[Usage Statistics](../features/usage-statistics.md)** - Controller usage metrics - **[Message Filter Rules](../features/message-filter-rules.md)** - Global and per-agent filters - **[Atlassian import](../features/atlassian-import.md)** - Atlassian site connections and Jira/Confluence import configurations +- **[Dynamic provider plugins](../features/dynamic-provider-plugins.md)** - Runtime provisioning and import provider extensions - **[Deployment Feature](../features/deployment.md)** - CI/CD configuration (invoked via controller proxy from the console) - **[Deployment Guide](../deployment/production-checklist.md)** - Production deployment guide diff --git a/docs/agenstra/applications/backend-agent-manager.md b/docs/agenstra/applications/backend-agent-manager.md index b6739b108..dded320e4 100644 --- a/docs/agenstra/applications/backend-agent-manager.md +++ b/docs/agenstra/applications/backend-agent-manager.md @@ -225,6 +225,15 @@ See the application docs and environment configuration for complete environment - `VNC_SERVER_DOCKER_IMAGE` - Docker image for VNC containers (default: `ghcr.io/forepath/agenstra-manager-vnc:latest`) - `VNC_SERVER_PUBLIC_PORTS` - Port range for VNC host port allocation (e.g., `"6080-6180"`) +**Dynamic provider plugins (optional):** + +- `DYNAMIC_AGENT_PROVIDERS` - Extra agent backend packages +- `DYNAMIC_PIPELINE_PROVIDERS` - Extra CI/CD provider packages +- `DYNAMIC_CHAT_FILTERS` - Extra chat filter packages +- `DYNAMIC_PROVIDER_PLUGIN_PATH` / `DYNAMIC_PROVIDER_PLUGIN_INSTALL` - Post-build plugin mount and startup install + +See [Dynamic provider plugins](../features/dynamic-provider-plugins.md). + ## Database Setup The application uses TypeORM and requires a database connection to be configured. See the application docs for database setup requirements and entity schema. @@ -303,6 +312,7 @@ Before deploying to production, ensure: - **[WebSocket Communication Feature](../features/websocket-communication.md)** - WebSocket communication guide - **[Deployment Feature](../features/deployment.md)** - CI/CD configuration and operations - **[Message Filter Rules](../features/message-filter-rules.md)** - Per-agent regex filters +- **[Dynamic provider plugins](../features/dynamic-provider-plugins.md)** - Custom agent, pipeline, and filter providers - **[Backend Agent Controller](./backend-agent-controller.md)** - Control plane and proxy paths used by the console - **[Deployment Guide](../deployment/production-checklist.md)** - Production deployment guide diff --git a/docs/agenstra/deployment/README.md b/docs/agenstra/deployment/README.md index 3c40d9387..8d510b7f5 100644 --- a/docs/agenstra/deployment/README.md +++ b/docs/agenstra/deployment/README.md @@ -51,6 +51,7 @@ Complete environment variables reference: - Authentication configuration - CORS and rate limiting - Server provisioning +- [Dynamic provider plugins](../features/dynamic-provider-plugins.md) — optional runtime extensions for Agenstra backends ## Deployment Architecture diff --git a/docs/agenstra/deployment/background-jobs.md b/docs/agenstra/deployment/background-jobs.md index 05e48069c..af558a4ce 100644 --- a/docs/agenstra/deployment/background-jobs.md +++ b/docs/agenstra/deployment/background-jobs.md @@ -24,7 +24,7 @@ Workers and schedulers assume the API has already applied schema migrations. Run Job registration (queue names, repeatable intervals, job names) lives in one file per app: - `apps/agenstra/backend-agent-controller/src/queue/job-registry.ts` -- `apps/agenstra/backend-billing-manager/src/queue/job-registry.ts` +- `apps/decabill/backend-billing-manager/src/queue/job-registry.ts` Coordinators fan out **unit jobs** (one subscription, one ticket, one import config, etc.). BullMQ `jobId` values prevent duplicate active work for the same entity. Custom job IDs use `.` separators and only allowed characters (alphanumeric, `.`, `-`, `_`, `~`) — e.g. `coordinator.filter-rules-sync`, `billing.subscription.`. Colons and slashes are not valid. diff --git a/docs/agenstra/deployment/docker-deployment.md b/docs/agenstra/deployment/docker-deployment.md index b66c57d2e..5ebcdcbac 100644 --- a/docs/agenstra/deployment/docker-deployment.md +++ b/docs/agenstra/deployment/docker-deployment.md @@ -31,6 +31,7 @@ The `docker-compose.yaml` includes: - Agent controller API container - Environment variable configuration - Volume mounts for data persistence +- Optional `./provider-plugins` mount for [dynamic provider plugins](../features/dynamic-provider-plugins.md) (`DYNAMIC_PROVIDER_PLUGIN_PATH`) ### Backend Agent Manager @@ -45,6 +46,7 @@ The `docker-compose.yaml` includes: - Agent manager API container - Docker socket mount (for agent containers) - Environment variable configuration +- Optional `./provider-plugins` mount for [dynamic provider plugins](../features/dynamic-provider-plugins.md) ### Frontend Agent Console diff --git a/docs/agenstra/deployment/environment-configuration.md b/docs/agenstra/deployment/environment-configuration.md index 1de0876b4..f965a6beb 100644 --- a/docs/agenstra/deployment/environment-configuration.md +++ b/docs/agenstra/deployment/environment-configuration.md @@ -78,6 +78,16 @@ These variables tune the **Atlassian Cloud** import scheduler and provider on th - `CONTEXT_IMPORT_ITEM_BUDGET` - Soft cap on import items processed **per config per run** for scheduler and on-demand runs (default: `25`). - `ATLASSIAN_IMPORT_DISABLED` - When set to `true`, the Atlassian import provider skips work for import runs (connections and configs remain manageable via the admin API). +### Dynamic provider plugins + +Optional runtime extensions for provisioning and context import. See [Dynamic provider plugins](../features/dynamic-provider-plugins.md) for resolution order, post-build mounts, and export contract. + +- `DYNAMIC_PROVISIONING_PROVIDERS` - Comma-separated extra provisioning packages (`alias=@forepath/pkg` or `alias=file:dir`). **Critical** registry; use `DYNAMIC_PROVIDERS_FAIL_FAST=true` in production when set. +- `DYNAMIC_CONTEXT_IMPORT_PROVIDERS` - Comma-separated extra context import provider packages (optional). +- `DYNAMIC_PROVIDERS_FAIL_FAST` - When `true`, abort startup if a **critical** dynamic provider fails to load. +- `DYNAMIC_PROVIDER_PLUGIN_PATH` - Absolute plugin root for post-build loading (unset by default; use `/var/lib/forepath/provider-plugins` with the compose volume when enabling plugins). +- `DYNAMIC_PROVIDER_PLUGIN_INSTALL` - Comma-separated `npm install` targets into the plugin path at container startup. + ## Backend Agent Manager ### Application Configuration @@ -174,6 +184,17 @@ When building `Dockerfile.api` images that mount `/var/run/docker.sock`: - `GIT_AUTHOR_NAME` - Git commit author name (default: `Agenstra`) - `GIT_AUTHOR_EMAIL` - Git commit author email (default: `noreply@agenstra.com`) +### Dynamic provider plugins + +Optional runtime extensions for agents, CI/CD pipelines, and chat filters. See [Dynamic provider plugins](../features/dynamic-provider-plugins.md). + +- `DYNAMIC_AGENT_PROVIDERS` - Comma-separated extra agent backend packages. +- `DYNAMIC_PIPELINE_PROVIDERS` - Comma-separated extra CI/CD provider packages. +- `DYNAMIC_CHAT_FILTERS` - Comma-separated extra chat filter packages. +- `DYNAMIC_PROVIDERS_FAIL_FAST` - When `true`, abort startup if a **critical** dynamic provider fails to load (manager registries are optional; this mainly affects shared loader policy when combined with critical env on other services). +- `DYNAMIC_PROVIDER_PLUGIN_PATH` - Absolute plugin root for post-build loading (unset by default; use `/var/lib/forepath/provider-plugins` with the compose volume when enabling plugins). +- `DYNAMIC_PROVIDER_PLUGIN_INSTALL` - Comma-separated `npm install` targets into the plugin path at container startup. + ## Backend Billing Manager ### Multi-tenancy @@ -189,7 +210,7 @@ Billing data and users are partitioned by **`tenant_id`**. HTTP clients send **` **API key scope (accepted risk [AR-007](../security/accepted-risks.md#ar-007--billing-multi-tenant-api-key-scope-static_api_key_tenant_id-unset)):** With **`STATIC_API_KEY`** and **without** **`STATIC_API_KEY_TENANT_ID`**, one deployment key grants **admin access to every tenant** in **`TENANTS`**, selected per request via **`X-Tenant`**. This is **intentional** (single shared automation credential). Set **`STATIC_API_KEY_TENANT_ID`** to bind the key to one tenant, or use **keycloak** / **users** for interactive multi-tenant console access. -See also the [billing feature README](../../libs/domains/agenstra/backend/feature-billing-manager/README.md). +See also [Billing Administration](../features/billing-administration.md) and the billing sections in this document. ## Frontend applications (Express SSR) diff --git a/docs/agenstra/features/README.md b/docs/agenstra/features/README.md index 06f604981..f4c3a53d9 100644 --- a/docs/agenstra/features/README.md +++ b/docs/agenstra/features/README.md @@ -21,6 +21,7 @@ Agenstra provides a complete set of features for managing distributed AI agent i - **Usage Statistics** - Controller-backed usage and filter metrics (distinct from container stats) - **Message Filter Rules** - Global and per-agent regex policies for chat traffic - **Atlassian import** - Admin-managed site connections and import configs (Jira and Confluence) into tickets and knowledge +- **Dynamic provider plugins** - Extend provisioning, agents, pipelines, context import, and chat filters via env-configured packages ## Features @@ -198,6 +199,18 @@ Admin-only **Atlassian Cloud** integrations: store encrypted site credentials, d - Import configuration CRUD with Jira or Confluence parameters and optional parent ticket or folder - Scheduled and manual import runs with configurable batching and budgets - Marker cleanup and optional marker release on ticket or knowledge delete +- Optional extra import providers via `DYNAMIC_CONTEXT_IMPORT_PROVIDERS` (see [Dynamic provider plugins](./dynamic-provider-plugins.md)) + +### [Dynamic provider plugins](./dynamic-provider-plugins.md) + +Extend Agenstra backends with extra provider packages **without forking** the controller or manager images. Supports baked-in deploy-graph dependencies and post-build volume mounts with optional startup `npm install`. + +**Key Capabilities**: + +- Add cloud provisioning providers on the controller (`DYNAMIC_PROVISIONING_PROVIDERS`) +- Add agent backends, CI/CD providers, and chat filters on the manager +- Mount `./provider-plugins` or install from registry/tarballs at container startup +- Tiered fail-fast for critical provisioning registry (`DYNAMIC_PROVIDERS_FAIL_FAST`) ## Feature Relationships @@ -217,6 +230,7 @@ graph TB TK[Tickets and Workspaces] ST[Usage Statistics] FR[Message Filter Rules] + DP[Dynamic Provider Plugins] SP --> CM CM --> AM @@ -238,6 +252,9 @@ graph TB FR --> Chat FR --> ST TK --> AM + DP --> SP + DP --> AM + DP --> DEP ``` ## Related Documentation diff --git a/docs/agenstra/features/agent-management.md b/docs/agenstra/features/agent-management.md index 24b00c0ad..a26276b74 100644 --- a/docs/agenstra/features/agent-management.md +++ b/docs/agenstra/features/agent-management.md @@ -101,7 +101,9 @@ Agenstra uses a plugin-based agent provider system. Each agent has an `agentType ### Adding New Agent Types -To add a new agent type, implement the `AgentProvider` interface: +Built-in agent providers are registered in `AgentsModule`. To add types **without rebuilding** the manager image, use [Dynamic provider plugins](./dynamic-provider-plugins.md) (`DYNAMIC_AGENT_PROVIDERS`). + +To add a provider in source, implement the `AgentProvider` interface: 1. Create a provider class implementing `AgentProvider` 2. Register the provider in `AgentsModule` @@ -202,6 +204,7 @@ For detailed API documentation, see the application and API reference docs linke - **[VNC Browser Access](./vnc-browser-access.md)** - Graphical browser access via VNC - **[Usage Statistics](./usage-statistics.md)** - Controller-side usage metrics - **[Message Filter Rules](./message-filter-rules.md)** - Regex filters +- **[Dynamic provider plugins](./dynamic-provider-plugins.md)** - Custom agent, pipeline, and filter providers - **[Backend Agent Manager Application](../applications/backend-agent-manager.md)** - Application details --- diff --git a/docs/agenstra/features/atlassian-import.md b/docs/agenstra/features/atlassian-import.md index fcac2bb01..0ab221ed3 100644 --- a/docs/agenstra/features/atlassian-import.md +++ b/docs/agenstra/features/atlassian-import.md @@ -6,7 +6,7 @@ The agent controller can **import work from Atlassian Cloud** into Agenstra usin - **Site connections** – Per-controller records for an Atlassian site: base URL, account email, and API token used for REST calls. Tokens are stored for server-side use only; list and detail APIs do not return secrets (see OpenAPI). - **Import configurations** – Each config binds a connection to a **workspace** (`clientId`), an import **kind** (`jira` or `confluence`), optional query scope (JQL, board id, CQL, space key, etc.), and optional **parent** targets in Agenstra (parent ticket for Jira swimlanes, parent folder for Confluence pages). Configs can be enabled or disabled; each run records `lastRunAt` and `lastError` when applicable. -- **Provider model** – Today the registered provider is **Atlassian** (`atlassian`). The controller uses a small factory so additional providers could be added later without changing the HTTP surface. +- **Provider model** – Today the registered provider is **Atlassian** (`atlassian`). The controller uses a small factory so additional providers could be added later without changing the HTTP surface. Register extra import backends with `DYNAMIC_CONTEXT_IMPORT_PROVIDERS` (see [Dynamic provider plugins](./dynamic-provider-plugins.md)). - **Execution** – A periodic **scheduler** loads enabled configs in batches and invokes the provider with an **item budget** per tick. Admins can also **run** a single config on demand from the console or `POST` the run endpoint. ## Why controller-native? @@ -58,6 +58,7 @@ Boards do not embed full import administration; operators manage imports from th - **[Tickets and Workspaces](./tickets-and-workspaces.md)** – Target model for Jira imports - **[Message Filter Rules](./message-filter-rules.md)** – Same admin / API-key authorization pattern on the controller - **[Authentication](./authentication.md)** – Roles and authentication modes +- **[Dynamic provider plugins](./dynamic-provider-plugins.md)** – `DYNAMIC_CONTEXT_IMPORT_PROVIDERS` and shared loader behavior - **[Backend Agent Controller](../applications/backend-agent-controller.md)** – Nest application and `/api` prefix - **[Frontend Agent Console](../applications/frontend-agent-console.md)** – Routes and NgRx feature wiring - **[API Reference](../api-reference/README.md)** – Where the bundled OpenAPI lives diff --git a/docs/agenstra/features/billing-administration.md b/docs/agenstra/features/billing-administration.md index 90bba287c..67facd71e 100644 --- a/docs/agenstra/features/billing-administration.md +++ b/docs/agenstra/features/billing-administration.md @@ -2,7 +2,7 @@ Admin-only features in the billing console for manual invoice management and customer billing profile CRUD. -See also: [feature-billing-manager README](../../libs/domains/agenstra/backend/feature-billing-manager/README.md) and [OpenAPI spec](../../libs/domains/agenstra/backend/feature-billing-manager/spec/openapi.yaml). +See also: [API Reference](../api-reference/README.md#billing-manager-http-api) for the published OpenAPI and AsyncAPI specifications. ## Access control @@ -21,7 +21,7 @@ All endpoints under `/admin/billing/*` require admin role (`@KeycloakRoles(ADMIN 3. `POST /admin/billing/invoices/{id}/issue` — issue draft (requires complete customer profile) 4. `DELETE /admin/billing/invoices/{id}` — delete draft only -Sequence diagram: [manual-invoice-administration.mmd](../../libs/domains/agenstra/backend/feature-billing-manager/docs/manual-invoice-administration.mmd) +The workflow steps above mirror the manual invoice administration sequence (create draft → update → issue or delete). **Frontend:** `/administration/billing` in the billing console — split layout with dashboard cards and charts on the left, invoice list (batch-loaded, client-side search, list-group style) on the right. diff --git a/docs/agenstra/features/deployment.md b/docs/agenstra/features/deployment.md index cd606c7a5..759c603cd 100644 --- a/docs/agenstra/features/deployment.md +++ b/docs/agenstra/features/deployment.md @@ -18,6 +18,7 @@ The deployment feature follows the same provider pattern used throughout Agenstr - **PipelineProvider Interface** - Unified interface for CI/CD providers - **PipelineProviderFactory** - Factory for managing multiple providers - **Provider Implementations** - GitHub Actions and GitLab CI/CD providers (extensible to Jenkins, Azure DevOps, etc.) +- **Runtime plugins** - Additional pipeline providers via `DYNAMIC_PIPELINE_PROVIDERS` (see [Dynamic provider plugins](./dynamic-provider-plugins.md)) - **Deployment Service** - Orchestrates pipeline operations - **Database Storage** - Stores deployment configurations and run history @@ -348,3 +349,4 @@ Deployment configuration can be included when creating or updating agents: - [Agent Management](./agent-management.md) - Agent lifecycle management - [Server Provisioning](./server-provisioning.md) - Automated server provisioning +- [Dynamic provider plugins](./dynamic-provider-plugins.md) - `DYNAMIC_PIPELINE_PROVIDERS` and shared loader behavior diff --git a/docs/agenstra/features/dynamic-provider-plugins.md b/docs/agenstra/features/dynamic-provider-plugins.md new file mode 100644 index 000000000..1396bf7ce --- /dev/null +++ b/docs/agenstra/features/dynamic-provider-plugins.md @@ -0,0 +1,188 @@ +# Dynamic provider plugins + +Agenstra backends can register **extra provider implementations at runtime** from comma-separated `DYNAMIC_*` environment variables. The same mechanism supports **baked-in** packages (present in the app image deploy graph) and **post-build** plugins (mounted or installed into a container path without rebuilding the image). + +This page covers **Agenstra** backends only: **agent controller** and **agent manager**. Decabill billing backends use the same loader for payment processors and billing UI metadata; see [Billing Administration](./billing-administration.md) and the [API Reference](../api-reference/README.md#billing-manager-http-api). + +## Overview + +Several registries accept a static built-in set plus optional plugins: + +| Backend | Env var | Criticality | Registers | +| ---------------- | ---------------------------------- | ----------- | --------------------------------- | +| Agent controller | `DYNAMIC_PROVISIONING_PROVIDERS` | critical | Cloud provisioning providers | +| Agent controller | `DYNAMIC_CONTEXT_IMPORT_PROVIDERS` | optional | External context import providers | +| Agent manager | `DYNAMIC_AGENT_PROVIDERS` | optional | Agent backend implementations | +| Agent manager | `DYNAMIC_PIPELINE_PROVIDERS` | optional | CI/CD pipeline providers | +| Agent manager | `DYNAMIC_CHAT_FILTERS` | optional | Chat filter plugins | + +Shared tuning: + +| Variable | Purpose | +| --------------------------------- | ------------------------------------------------------------------------- | +| `DYNAMIC_PROVIDERS_FAIL_FAST` | When `true`, **critical** registries abort startup on load errors | +| `DYNAMIC_PROVIDER_PLUGIN_PATH` | Absolute plugin root inside the container (post-build loading) | +| `DYNAMIC_PROVIDER_PLUGIN_INSTALL` | Comma-separated `npm install` targets run at startup into the plugin path | + +Implementation lives in `@forepath/shared/backend/util-dynamic-provider-registry`. Export contract, API surface, and security rules are documented in the sections below. + +## Resolution order + +For each `DYNAMIC_*` entry the loader: + +1. **Baked-in** — resolves the package from `/app/package.json` (image build / `nx run :prune` graph). +2. **Plugin path** — looks up the package by `package.json` `name` under `DYNAMIC_PROVIDER_PLUGIN_PATH` (immediate child directories and `node_modules` after startup install). +3. **Fail** — logs and skips the entry, or aborts startup when the registry is **critical** and `DYNAMIC_PROVIDERS_FAIL_FAST=true`. + +Baked-in wins when the same package exists in both places. + +```mermaid +flowchart TD + env[DYNAMIC_* entry] --> parse[parseProviderPackageSpec] + parse --> resolve[resolveProviderLoadTarget] + resolve --> baked{Baked into /app?} + baked -->|yes| loadBaked[loadProviderModule] + baked -->|no| plugin{Plugin path set?} + plugin -->|yes| index[Index by package.json name] + index --> loadPlugin[load from filesystem] + plugin -->|no| fail[Skip or fail-fast] + loadBaked --> register[registerDynamicProviders] + loadPlugin --> register +``` + +## Config format + +```bash +# alias=@package/specifier (package name whether baked-in or mounted) +DYNAMIC_PROVISIONING_PROVIDERS=acme=@forepath/agenstra/backend/provisioning-acme + +# PascalCase alias selects a named class export from the package +DYNAMIC_AGENT_PROVIDERS=AcmeAgent=@forepath/agenstra/backend/agent-acme + +# bare specifier +DYNAMIC_PIPELINE_PROVIDERS=@forepath/agenstra/backend/pipeline-acme + +# file: entry — directory relative to DYNAMIC_PROVIDER_PLUGIN_PATH +DYNAMIC_PROVISIONING_PROVIDERS=acme=file:provisioning-acme +``` + +Allowed package name prefixes: `@forepath/`, `@agenstra/`. Do not combine `file:` with an `@forepath/` specifier on the same entry. + +## Plugin package contract + +External packages must export one of: + +1. **`createProvider`** (preferred) — `(moduleRef: ModuleRef) => T | Promise` +2. **Named PascalCase class** — via entry alias or `package.json`: + +```json +{ + "forepath": { + "providerExport": "AcmeProvisioningProvider" + } +} +``` + +Declare Nest and host dependencies as **peerDependencies** so they resolve from `/app/node_modules`. + +Generic `provider` / `Provider` exports are not accepted for production plugin packages. + +## Baked-in plugins (image build) + +1. Add the provider package to the backend app deploy graph (`implicitDependencies`, import edge, or pruned `package.json`). +2. Set the relevant `DYNAMIC_*` variable. +3. Rebuild the container image (`nx run :api-container-image`). + +Verify the package appears under `dist/apps/agenstra//package.json` or `workspace_modules/` after `nx run :prune`. + +## Post-build plugins (no image rebuild) + +Use this when operators add providers **after** the image exists. + +1. Build the plugin (`nx build `) to produce compiled JS and `package.json`. +2. Deliver the plugin: + - **Mount** — copy the folder into host `./provider-plugins/` (compose mounts it at `/var/lib/forepath/provider-plugins` when you enable plugins), and/or + - **Install** — set `DYNAMIC_PROVIDER_PLUGIN_INSTALL` (see below). +3. Set `DYNAMIC_PROVIDER_PLUGIN_PATH` to `/var/lib/forepath/provider-plugins` (or your chosen absolute path inside the container). +4. Set `DYNAMIC_*` to reference the package **name** or a `file:` directory. +5. Restart the container. + +At startup, when `DYNAMIC_PROVIDER_PLUGIN_PATH` is set, the API entrypoint runs `install-provider-plugins.js` before `main.js`. Install failures fail the container start. + +### Startup install format + +```bash +# Registry package (requires .npmrc / token in image or mounted secret) +DYNAMIC_PROVIDER_PLUGIN_INSTALL=@forepath/agenstra-provisioning-acme@1.2.0 + +# Paths under the plugin root (absolute or relative to plugin path) +DYNAMIC_PROVIDER_PLUGIN_INSTALL=file:/var/lib/forepath/provider-plugins/acme.tgz,file:provisioning-acme + +# Mixed +DYNAMIC_PROVIDER_PLUGIN_INSTALL=file:acme.tgz,@forepath/agenstra/backend/agent-acme@2.0.0 +``` + +Compose mounts `./provider-plugins` read-write when plugins are enabled. Use `:ro` only when plugins are pre-copied and `DYNAMIC_PROVIDER_PLUGIN_INSTALL` is unset. + +### Verify + +```bash +docker exec node -e "require('/var/lib/forepath/provider-plugins/node_modules/@forepath/...')" +``` + +Or inspect startup logs for `DynamicProviderRegistry` / loader errors. + +## Startup error policy + +| Registry criticality | `DYNAMIC_PROVIDERS_FAIL_FAST` | On load error | +| -------------------- | ----------------------------- | ------------------ | +| optional | any | Log and skip entry | +| critical | unset / `false` | Log and skip entry | +| critical | `true` | Abort startup | + +**Production:** set `DYNAMIC_PROVIDERS_FAIL_FAST=true` when `DYNAMIC_PROVISIONING_PROVIDERS` is non-empty. + +## Security + +- Package `name` in indexed `package.json` files must use allowlisted prefixes (`@forepath/`, `@agenstra/`). +- `file:` paths are resolved under `DYNAMIC_PROVIDER_PLUGIN_PATH` only; `..` traversal and paths outside the plugin root are rejected (`realpath` guard). +- Private registry installs require operator-supplied `.npmrc` or token mounts; the loader does not manage registry auth. + +## Where each registry is used + +### Agent controller + +- **Provisioning** — [Server Provisioning](./server-provisioning.md) lists built-in Hetzner and DigitalOcean providers; `DYNAMIC_PROVISIONING_PROVIDERS` adds more for `GET /api/clients/provisioning/providers` and provision flows. +- **Context import** — [Atlassian import](./atlassian-import.md) registers Atlassian statically; `DYNAMIC_CONTEXT_IMPORT_PROVIDERS` can add other import backends to the same factory. + +### Agent manager + +- **Agents** — [Agent Management](./agent-management.md); `DYNAMIC_AGENT_PROVIDERS` extends agent types beyond built-in cursor/openclaw/opencode providers. +- **Pipelines** — [Deployment](./deployment.md); `DYNAMIC_PIPELINE_PROVIDERS` adds CI/CD backends. +- **Chat filters** — [Message Filter Rules](./message-filter-rules.md); `DYNAMIC_CHAT_FILTERS` adds filter implementations on the manager. + +## Docker Compose (optional plugins) + +`DYNAMIC_PROVIDER_PLUGIN_PATH` is **unset by default** in compose. Enable post-build plugins explicitly: + +```yaml +environment: + DYNAMIC_PROVIDER_PLUGIN_PATH: /var/lib/forepath/provider-plugins + DYNAMIC_PROVIDER_PLUGIN_INSTALL: ${DYNAMIC_PROVIDER_PLUGIN_INSTALL:-} +volumes: + - ./provider-plugins:/var/lib/forepath/provider-plugins +``` + +The mount is read-write so `DYNAMIC_PROVIDER_PLUGIN_INSTALL` can write `package.json` and `node_modules`. Use `:ro` only when plugins are pre-copied and startup install is disabled. + +See [Docker Deployment](../deployment/docker-deployment.md) and [Applications](../applications/README.md). + +## Related documentation + +- **[Environment configuration](../deployment/environment-configuration.md)** — `DYNAMIC_*` variable reference +- **[Server Provisioning](./server-provisioning.md)** — Built-in provisioning providers +- **[Agent Management](./agent-management.md)** — Agent types and lifecycle +- **[Atlassian import](./atlassian-import.md)** — Built-in context import provider +- **[Deployment](./deployment.md)** — CI/CD providers on the manager +- **[Backend Agent Controller](../applications/backend-agent-controller.md)** — Controller env and compose +- **[Backend Agent Manager](../applications/backend-agent-manager.md)** — Manager env and compose diff --git a/docs/agenstra/features/server-provisioning.md b/docs/agenstra/features/server-provisioning.md index 58e6b1f33..c3cd1b2f4 100644 --- a/docs/agenstra/features/server-provisioning.md +++ b/docs/agenstra/features/server-provisioning.md @@ -14,14 +14,16 @@ Server provisioning enables you to automatically: ## Supported Providers -### Hetzner Cloud +Built-in providers are registered statically. Additional cloud backends can be added at runtime via [Dynamic provider plugins](./dynamic-provider-plugins.md) (`DYNAMIC_PROVISIONING_PROVIDERS`). + +#### Hetzner Cloud - **Provider Type**: `hetzner` - **Requires**: `HETZNER_API_TOKEN` environment variable - **Server Types**: Various sizes (e.g., `cx11`, `cx21`, `cx31`) - **Locations**: Multiple datacenters (e.g., `fsn1`, `nbg1`, `hel1`) -### DigitalOcean +#### DigitalOcean - **Provider Type**: `digital-ocean` - **Requires**: `DIGITALOCEAN_API_TOKEN` environment variable @@ -146,6 +148,7 @@ For detailed API documentation, see the application and API reference docs linke ## Related Documentation - **[Client Management](./client-management.md)** - Managing clients +- **[Dynamic provider plugins](./dynamic-provider-plugins.md)** - Adding custom provisioning providers - **[Backend Agent Controller Application](../applications/backend-agent-controller.md)** - Application details --- diff --git a/docs/agenstra/features/websocket-communication.md b/docs/agenstra/features/websocket-communication.md index 420db364d..57d2e788a 100644 --- a/docs/agenstra/features/websocket-communication.md +++ b/docs/agenstra/features/websocket-communication.md @@ -41,7 +41,7 @@ See `libs/domains/agenstra/backend/feature-agent-controller/spec/asyncapi.yaml` The billing console can open a second Socket.IO connection to the **billing-manager** status gateway (default namespace `/billing`, separate TCP port from REST). Handshake auth matches HTTP (`Bearer` JWT for users or Keycloak). **Static API key** auth does not receive a user-scoped billing stream; `subscribeDashboardStatus` is rejected with an `error` event, consistent with REST returning "User not authenticated" for API-key-only requests. -The server selects subscriptions **only** from the authenticated user’s data on every poll tick and emits `dashboardStatusUpdate` **only** to that socket (no rooms). See `libs/domains/agenstra/backend/feature-billing-manager/spec/asyncapi.yaml`. +The server selects subscriptions **only** from the authenticated user’s data on every poll tick and emits `dashboardStatusUpdate` **only** to that socket (no rooms). See `libs/domains/decabill/backend/feature-billing-manager/spec/asyncapi.yaml`. ## Connection Flow diff --git a/docs/agenstra/security/accepted-risks.md b/docs/agenstra/security/accepted-risks.md index a55ca7d17..4b71172ba 100644 --- a/docs/agenstra/security/accepted-risks.md +++ b/docs/agenstra/security/accepted-risks.md @@ -8,19 +8,19 @@ This register records **explicit risk acceptance** for product and deployment co ## AR-001 — Provisioning SSH (cloud-init templates) -| Field | Recorded value | -| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **ID** | AR-001 | -| **Area** | Provisioning SSH in cloud-init templates | -| **Configuration** | **`PermitRootLogin yes`** and **root** `authorized_keys` installed via provisioning scripts (`libs/domains/agenstra/backend/feature-billing-manager/.../agent-controller.utils.ts`, `agent-manager.utils.ts`) | -| **Residual risk** | Compromise of the provisioning SSH private key or leakage of user-data/metadata can yield **root** on affected instances. | -| **Mitigations in scope of this repo (templates)** | SSH **key-based** access; **password authentication disabled** in generated `sshd` configuration. | -| **Compensating controls (deployer / org)** | Restrict network access (security groups, allowlisted IPs, bastion), rotate keys, monitor instances, minimize secrets in user-data. | -| **Risk owner** | Maintaining party for this repository and product security documentation (Forepath). | -| **Acceptor** | Repository maintainer (acceptance recorded in project documentation). | -| **Acceptance date** | **2026-05-06** | -| **Next review date** | **2027-05-06** | -| **Rationale (business / technical)** | First-boot automation prioritizes operational simplicity for provisioned hosts. Non-root SSH and **`PermitRootLogin no`** remain the documented hardening path when constraints allow. | +| Field | Recorded value | +| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **ID** | AR-001 | +| **Area** | Provisioning SSH in cloud-init templates | +| **Configuration** | **`PermitRootLogin yes`** and **root** `authorized_keys` installed via provisioning scripts (`libs/domains/decabill/backend/feature-billing-manager/src/lib/utils/cloud-init/agent-controller.utils.ts`, `agent-manager.utils.ts`) | +| **Residual risk** | Compromise of the provisioning SSH private key or leakage of user-data/metadata can yield **root** on affected instances. | +| **Mitigations in scope of this repo (templates)** | SSH **key-based** access; **password authentication disabled** in generated `sshd` configuration. | +| **Compensating controls (deployer / org)** | Restrict network access (security groups, allowlisted IPs, bastion), rotate keys, monitor instances, minimize secrets in user-data. | +| **Risk owner** | Maintaining party for this repository and product security documentation (Forepath). | +| **Acceptor** | Repository maintainer (acceptance recorded in project documentation). | +| **Acceptance date** | **2026-05-06** | +| **Next review date** | **2027-05-06** | +| **Rationale (business / technical)** | First-boot automation prioritizes operational simplicity for provisioned hosts. Non-root SSH and **`PermitRootLogin no`** remain the documented hardening path when constraints allow. | #### Operator summary (AR-001) @@ -121,10 +121,10 @@ New windows are **allowed** by design. Risk is **lower** than in a general-purpo | Field | Recorded value | | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **ID** | AR-006 | -| **Area** | **Trivy** vulnerability scanning ([`trivy.yaml`](../../../trivy.yaml), pull-request CI, pre-commit, local image scans—not the release workflow) | +| **Area** | **Trivy** vulnerability scanning (`trivy.yaml` at repository root, pull-request CI, pre-commit, local image scans—not the release workflow) | | **Configuration** | **`vulnerability.ignore-unfixed: true`** — findings **without a Fixed Version** (no vendor/upstream fix published yet) are **excluded from the fail gate**. Only **CRITICAL** severities with an available fix fail CI and local hooks (see [`ci-security-scanning.md`](./ci-security-scanning.md)). | | **Residual risk** | Known **CRITICAL** issues may remain in dependencies, base images, or OS packages until upstream publishes a fix; they are visible in SARIF/report output but do not block merge or release. | -| **Mitigations in scope of this repo** | Trivy still scans for **vuln**, **secret**, and **misconfig**; **HIGH** and below are report-only; per-CVE exceptions use [`.trivyignore`](../../../.trivyignore) with traceability. SBOM publication and Dependency Track on release provide ongoing visibility. | +| **Mitigations in scope of this repo** | Trivy still scans for **vuln**, **secret**, and **misconfig**; **HIGH** and below are report-only; per-CVE exceptions use `.trivyignore` with traceability. SBOM publication and Dependency Track on release provide ongoing visibility. | | **Compensating controls (deployer)** | Monitor GitHub Security / SARIF artifacts; patch when fixes ship; track accepted CVEs in `.trivyignore` only when a fix exists but cannot be applied yet (not for permanently unfixed issues). | | **Risk owner** | Maintaining party for this repository and product security documentation (Forepath). | | **Acceptor** | Repository maintainer (acceptance recorded in project documentation). | @@ -143,7 +143,7 @@ New windows are **allowed** by design. Risk is **lower** than in a general-purpo | Field | Recorded value | | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **ID** | AR-007 | -| **Area** | **Billing manager** multi-tenancy with **`AUTHENTICATION_METHOD=api-key`** (or inferred api-key when **`STATIC_API_KEY`** is set). Implementation: `TenantUserGuard` in `libs/domains/agenstra/backend/feature-billing-manager/src/lib/guards/tenant-user.guard.ts`; admin bypass via `ensureAdmin()` in `billing-access.utils.ts`. | +| **Area** | **Billing manager** multi-tenancy with **`AUTHENTICATION_METHOD=api-key`** (or inferred api-key when **`STATIC_API_KEY`** is set). Implementation: `TenantUserGuard` in `libs/domains/decabill/backend/feature-billing-manager/src/lib/guards/tenant-user.guard.ts`; admin bypass via `ensureAdmin()` in `billing-access.utils.ts`. | | **Configuration** | A **single** deployment-wide **`STATIC_API_KEY`**. **`STATIC_API_KEY_TENANT_ID`** is **optional**. When unset, a valid API key is accepted for **any** tenant id allowed by **`TENANTS`** / **`X-Tenant`** (each request selects the tenant via header). API key auth is treated as **admin** for billing admin routes. | | **Residual risk** | Anyone who possesses **`STATIC_API_KEY`** can read and mutate **all** configured tenants’ billing data by changing **`X-Tenant`**, not only one tenant. This is **cross-tenant admin access** from a single shared secret. | | **Mitigations in scope of this repo** | **`STATIC_API_KEY_TENANT_ID`** optionally binds API key auth to one tenant (must match **`X-Tenant`**). User/session auth (**keycloak** / **users**) enforces per-user **`tenant_id`** regardless of this setting. WebSocket dashboard status does **not** stream data to API key clients. | @@ -156,7 +156,7 @@ New windows are **allowed** by design. Risk is **lower** than in a general-purpo #### Operator summary (AR-007) -With **`STATIC_API_KEY`** and **without** **`STATIC_API_KEY_TENANT_ID`**, the key is **not** limited to one tenant: it grants **admin-level access to every tenant** allowed by **`TENANTS`**, selected per request via **`X-Tenant`**. That behavior is **accepted** because there is only one deployment key. To restrict API key use to a single tenant, set **`STATIC_API_KEY_TENANT_ID`**. Interactive users (Keycloak/JWT) remain scoped to their own tenant. See **[Environment configuration — Billing manager multi-tenancy](../deployment/environment-configuration.md#backend-billing-manager)** and the **[billing feature README](../../libs/domains/agenstra/backend/feature-billing-manager/README.md)**. +With **`STATIC_API_KEY`** and **without** **`STATIC_API_KEY_TENANT_ID`**, the key is **not** limited to one tenant: it grants **admin-level access to every tenant** allowed by **`TENANTS`**, selected per request via **`X-Tenant`**. That behavior is **accepted** because there is only one deployment key. To restrict API key use to a single tenant, set **`STATIC_API_KEY_TENANT_ID`**. Interactive users (Keycloak/JWT) remain scoped to their own tenant. See **[Environment configuration — Billing manager multi-tenancy](../deployment/environment-configuration.md#backend-billing-manager)** and **[Billing Administration](../features/billing-administration.md)**. --- diff --git a/docs/agenstra/security/ci-security-scanning.md b/docs/agenstra/security/ci-security-scanning.md index 55c0f6b28..c36881d7e 100644 --- a/docs/agenstra/security/ci-security-scanning.md +++ b/docs/agenstra/security/ci-security-scanning.md @@ -1,6 +1,6 @@ # CI security scanning (Trivy) -Agenstra uses [Trivy](https://trivy.dev/) in GitHub Actions for automated vulnerability, secret, and misconfiguration detection. Defaults are defined in [`trivy.yaml`](../../../trivy.yaml) at the repository root. +Agenstra uses [Trivy](https://trivy.dev/) in GitHub Actions for automated vulnerability, secret, and misconfiguration detection. Defaults are defined in `trivy.yaml` at the repository root. ## What is scanned @@ -14,11 +14,11 @@ Scanners enabled for filesystem scans: **vuln**, **secret**, **misconfig**. ## Workflows -| Workflow | Jobs | -| ------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | -| [`.github/workflows/pull-request-checks.yml`](../../../.github/workflows/pull-request-checks.yml) | `trivy-filesystem`, `trivy-config`, plus image scans after each container build job | +| Workflow | Jobs | +| ------------------------------------------- | ----------------------------------------------------------------------------------- | +| `.github/workflows/pull-request-checks.yml` | `trivy-filesystem`, `trivy-config`, plus image scans after each container build job | -The [release workflow](../../../.github/workflows/release.yml) does **not** run Trivy vulnerability scans; pull-request scans are the CI gate before merge. Releases publish **Nx service SBOMs** and **Trivy CycloneDX container image SBOMs** (when images are built) to Dependency Track and R2. +The `.github/workflows/release.yml` workflow does **not** run Trivy vulnerability scans; pull-request scans are the CI gate before merge. Releases publish **Nx service SBOMs** and **Trivy CycloneDX container image SBOMs** (when images are built) to Dependency Track and R2. ## Severity policy @@ -38,8 +38,8 @@ SARIF categories include `trivy-fs`, `trivy-config`, and `trivy-images-*` on pul ## Triage and exceptions 1. **Prefer fixing** — upgrade dependencies, base images, or configuration. -2. **Documented ignore** — open a PR that adds the CVE to [`.trivyignore`](../../../.trivyignore), reference an **[accepted-risk](./accepted-risks.md)** entry (or document a false positive), and note a **review/expiry date** in the PR description. -3. **Do not** weaken [`trivy.yaml`](../../../trivy.yaml) for one-off exceptions. +2. **Documented ignore** — open a PR that adds the CVE to `.trivyignore`, reference an **[accepted-risk](./accepted-risks.md)** entry (or document a false positive), and note a **review/expiry date** in the PR description. +3. **Do not** weaken `trivy.yaml` for one-off exceptions. See **[Accepted risks](./accepted-risks.md)** for deliberate product-level deviations (separate from CVE ignores), including **[AR-006](./accepted-risks.md#ar-006--ci--local-trivy-unfixed-vulnerabilities-not-gated)** (unfixed vulnerabilities are not pipeline blockers). @@ -51,7 +51,7 @@ See **[Accepted risks](./accepted-risks.md)** for deliberate product-level devia ./tools/ci/trivy-pre-commit.sh ``` -This runs automatically via [`.husky/pre-commit`](../../../.husky/pre-commit) on every commit. Install [Trivy](https://trivy.dev/latest/docs/installation/) before your first commit on a machine; commits fail if `trivy` is not on `PATH`. To skip all Husky hooks for one commit (use sparingly): `git commit --no-verify`. +This runs automatically via `.husky/pre-commit` on every commit. Install [Trivy](https://trivy.dev/latest/docs/installation/) before your first commit on a machine; commits fail if `trivy` is not on `PATH`. To skip all Husky hooks for one commit (use sparingly): `git commit --no-verify`. **Manual full scans:** @@ -84,5 +84,5 @@ After building images locally: ## Related documentation -- [`SECURITY.md`](../../../SECURITY.md) +- **[Security overview](./README.md)** - **[Vulnerability reporting and artifacts](./vulnerability-reporting-and-artifacts.md)** diff --git a/docs/agenstra/security/operational-hardening.md b/docs/agenstra/security/operational-hardening.md index 572b16520..538e22c65 100644 --- a/docs/agenstra/security/operational-hardening.md +++ b/docs/agenstra/security/operational-hardening.md @@ -42,7 +42,7 @@ Resolution is implemented in **`getAuthenticationMethod`** (`libs/domains/identi **Accepted risk [AR-007](./accepted-risks.md#ar-007--billing-multi-tenant-api-key-scope-static_api_key_tenant_id-unset):** With **`STATIC_API_KEY`** and **without** **`STATIC_API_KEY_TENANT_ID`**, one deployment API key grants **admin access to every tenant** in **`TENANTS`** (tenant chosen per request via **`X-Tenant`**). This is **intentional** for a single shared automation key. Interactive **keycloak** / **users** sessions remain limited to the user’s tenant. -Code: `libs/domains/agenstra/backend/feature-billing-manager/src/lib/guards/tenant-user.guard.ts`, `libs/domains/shared/backend/util-http-context/src/lib/tenant-id.middleware.ts`. +Code: `libs/domains/decabill/backend/feature-billing-manager/src/lib/guards/tenant-user.guard.ts`, `libs/domains/shared/backend/util-http-context/src/lib/tenant-id.middleware.ts`. ## Agent Controller — remote client endpoints (SSRF) diff --git a/docs/agenstra/security/vulnerability-reporting-and-artifacts.md b/docs/agenstra/security/vulnerability-reporting-and-artifacts.md index a0fa86db6..8d8640199 100644 --- a/docs/agenstra/security/vulnerability-reporting-and-artifacts.md +++ b/docs/agenstra/security/vulnerability-reporting-and-artifacts.md @@ -68,7 +68,7 @@ Researchers who report valid issues may be recognized per project policy. ## Continuous scanning (CI) -Pull requests run **[Trivy](https://trivy.dev/)** scans (dependencies, secrets, misconfigurations, and container images). CRITICAL findings fail the PR pipeline unless documented in [`.trivyignore`](../../../.trivyignore). The release workflow does not re-run Trivy. +Pull requests run **[Trivy](https://trivy.dev/)** scans (dependencies, secrets, misconfigurations, and container images). CRITICAL findings fail the PR pipeline unless documented in `.trivyignore`. The release workflow does not re-run Trivy. See **[CI security scanning (Trivy)](./ci-security-scanning.md)** for workflows, severity policy, SARIF locations, and local reproduction. @@ -101,7 +101,7 @@ Shared R2 credentials: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_REGION - **Service SBOMs** — Nx `sbom` target (`dist/sboms/.cdx.json`) - **Container image SBOMs** — Trivy CycloneDX (`dist/sboms/container-.cdx.json`) for images built in the release pipeline -Pull requests upload service and container SBOM files (separate CycloneDX files per image) as the **`sbom-artifacts`** workflow artifact. Releases upload each container image SBOM to Dependency-Track with `forepath/gh-upload-sbom@v2` (one upload per file, matrix job). Set the Dependency-Track **parent project UUID** for all container image projects in [`.github/workflows/release.yml`](../../../.github/workflows/release.yml) (`prepare-container-image-sbom-uploads` → `env.CONTAINER_IMAGE_SBOM_PARENT`). +Pull requests upload service and container SBOM files (separate CycloneDX files per image) as the **`sbom-artifacts`** workflow artifact. Releases upload each container image SBOM to Dependency-Track with `forepath/gh-upload-sbom@v2` (one upload per file, matrix job). Set the Dependency-Track **parent project UUID** for all container image projects in `.github/workflows/release.yml` (`prepare-container-image-sbom-uploads` → `env.CONTAINER_IMAGE_SBOM_PARENT`). Resolve `` from your deployment or from [Downloads](https://downloads.agenstra.com/), then substitute it in the path above. diff --git a/libs/domains/agenstra/backend/feature-agent-controller/project.json b/libs/domains/agenstra/backend/feature-agent-controller/project.json index 6922213cb..c1630c295 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/project.json +++ b/libs/domains/agenstra/backend/feature-agent-controller/project.json @@ -4,7 +4,10 @@ "sourceRoot": "libs/domains/agenstra/backend/feature-agent-controller/src", "projectType": "library", "tags": ["domain:agenstra", "scope:backend", "type:feature"], - "implicitDependencies": ["agenstra-backend-feature-agent-manager"], + "implicitDependencies": [ + "agenstra-backend-feature-agent-manager", + "shared-backend-util-dynamic-provider-registry" + ], "targets": { "test": { "executor": "@nx/jest:jest", diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts index a8f5e7472..4036867ab 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts @@ -1,3 +1,7 @@ +import { + DynamicProviderLoaderService, + registerDynamicProviders, +} from '@forepath/shared/backend/util-dynamic-provider-registry'; import { ClientAgentCredentialEntity, ClientAgentCredentialsRepository, @@ -51,6 +55,7 @@ import { TicketsBoardGateway } from '../gateways/tickets-board.gateway'; import { DigitalOceanProvider } from '../providers/provisioning/digital-ocean.provider'; import { HetznerProvider } from '../providers/provisioning/hetzner.provider'; import { ProvisioningProviderFactory } from '../providers/provisioning-provider.factory'; +import { ProvisioningProvider } from '../providers/provisioning-provider.interface'; import { ClientsRepository } from '../repositories/clients.repository'; import { ProvisioningReferencesRepository } from '../repositories/provisioning-references.repository'; import { TicketAutomationRunsStatusRepository } from '../repositories/ticket-automation-runs-status.repository'; @@ -177,20 +182,30 @@ const authMethod = getAuthenticationMethod(); ProvisioningReferencesRepository, HetznerProvider, DigitalOceanProvider, + DynamicProviderLoaderService, StatisticsAgentSyncService, { provide: 'PROVISIONING_PROVIDERS', - useFactory: ( + useFactory: async ( factory: ProvisioningProviderFactory, hetzner: HetznerProvider, digitalOcean: DigitalOceanProvider, + dynamicLoader: DynamicProviderLoaderService, ) => { factory.registerProvider(hetzner); factory.registerProvider(digitalOcean); + await registerDynamicProviders({ + envKey: 'DYNAMIC_PROVISIONING_PROVIDERS', + criticality: 'critical', + register: (provider) => factory.registerProvider(provider), + dynamicLoader, + loggerContext: 'ProvisioningProviderFactory', + }); + return factory; }, - inject: [ProvisioningProviderFactory, HetznerProvider, DigitalOceanProvider], + inject: [ProvisioningProviderFactory, HetznerProvider, DigitalOceanProvider, DynamicProviderLoaderService], }, ], exports: [ diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/context-import.module.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/context-import.module.ts index 6b9585012..adf047584 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/context-import.module.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/context-import.module.ts @@ -1,3 +1,7 @@ +import { + DynamicProviderLoaderService, + registerDynamicProviders, +} from '@forepath/shared/backend/util-dynamic-provider-registry'; import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -6,6 +10,7 @@ import { AtlassianSiteConnectionEntity } from '../entities/atlassian-site-connec import { ExternalImportConfigEntity } from '../entities/external-import-config.entity'; import { ExternalImportSyncMarkerEntity } from '../entities/external-import-sync-marker.entity'; import { ExternalImportProviderFactory } from '../providers/external-import-provider.factory'; +import type { ExternalContextImportProvider } from '../providers/external-import-provider.interface'; import { CONTEXT_IMPORT_PROVIDERS } from '../providers/external-import-provider.tokens'; import { AtlassianImportProvider } from '../providers/import/atlassian-external-import.provider'; import { AtlassianSiteConnectionService } from '../services/atlassian-site-connection.service'; @@ -32,14 +37,27 @@ import { ClientsModule } from './clients.module'; ExternalImportConfigService, ContextImportOrchestratorService, AtlassianImportProvider, + DynamicProviderLoaderService, { provide: CONTEXT_IMPORT_PROVIDERS, - useFactory: (factory: ExternalImportProviderFactory, atlassian: AtlassianImportProvider) => { + useFactory: async ( + factory: ExternalImportProviderFactory, + atlassian: AtlassianImportProvider, + dynamicLoader: DynamicProviderLoaderService, + ) => { factory.registerProvider(atlassian); + await registerDynamicProviders({ + envKey: 'DYNAMIC_CONTEXT_IMPORT_PROVIDERS', + criticality: 'optional', + register: (provider) => factory.registerProvider(provider), + dynamicLoader, + loggerContext: 'ExternalImportProviderFactory', + }); + return factory; }, - inject: [ExternalImportProviderFactory, AtlassianImportProvider], + inject: [ExternalImportProviderFactory, AtlassianImportProvider, DynamicProviderLoaderService], }, ], exports: [ExternalImportSyncMarkerService, ContextImportOrchestratorService, ExternalImportConfigService], diff --git a/libs/domains/agenstra/backend/feature-agent-manager/README.md b/libs/domains/agenstra/backend/feature-agent-manager/README.md index e1c124d10..d168d10cd 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/README.md +++ b/libs/domains/agenstra/backend/feature-agent-manager/README.md @@ -709,6 +709,15 @@ nx test agenstra-backend-feature-agent-manager --coverage - `GIT_AUTHOR_NAME` - Git commit author name (optional, defaults to 'Agenstra') - `GIT_AUTHOR_EMAIL` - Git commit author email (optional, defaults to 'noreply@agenstra.com') +### Dynamic provider plugins (optional) + +- `DYNAMIC_AGENT_PROVIDERS` - Comma-separated extra agent backend packages (`alias=@forepath/pkg`) +- `DYNAMIC_PIPELINE_PROVIDERS` - Comma-separated extra CI/CD provider packages +- `DYNAMIC_CHAT_FILTERS` - Comma-separated extra chat filter packages +- `DYNAMIC_PROVIDERS_FAIL_FAST` - When `true`, abort startup if critical dynamic providers fail to load + +Plugin packages must be runtime dependencies of the agent-manager backend app. See `@forepath/shared/backend/util-dynamic-provider-registry` README. + ### Git Repository Environment Variables When creating agents, the workspace Git repository is initialized in one of two modes (see `gitRepositorySetupMode` on `CreateAgentDto` or `GIT_REPOSITORY_SETUP_MODE`): diff --git a/libs/domains/agenstra/backend/feature-agent-manager/project.json b/libs/domains/agenstra/backend/feature-agent-manager/project.json index dd5ef586b..15be39f9f 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/project.json +++ b/libs/domains/agenstra/backend/feature-agent-manager/project.json @@ -5,6 +5,7 @@ "sourceRoot": "libs/domains/agenstra/backend/feature-agent-manager/src", "projectType": "library", "tags": ["domain:agenstra", "scope:backend", "type:feature"], + "implicitDependencies": ["shared-backend-util-dynamic-provider-registry"], "targets": { "test": { "executor": "@nx/jest:jest", diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts index b84cfb737..dc2b0efe0 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts @@ -1,3 +1,7 @@ +import { + DynamicProviderLoaderService, + registerDynamicProviders, +} from '@forepath/shared/backend/util-dynamic-provider-registry'; import { PasswordService } from '@forepath/identity/backend'; import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -22,6 +26,7 @@ import { RegexFilterRuleEntity } from '../entities/regex-filter-rule.entity'; import { WorkspaceConfigurationOverrideEntity } from '../entities/workspace-configuration-override.entity'; import { AgentsGateway } from '../gateways/agents.gateway'; import { AgentProviderFactory } from '../providers/agent-provider.factory'; +import type { AgentProvider } from '../providers/agent-provider.interface'; import { CursorAgentProvider } from '../providers/agents/cursor-agent.provider'; import { OpenClawAgentProvider } from '../providers/agents/openclaw-agent.provider'; import { OpenCodeAgentProvider } from '../providers/agents/opencode-agent.provider'; @@ -33,6 +38,8 @@ import { IncomingChatFilter } from '../providers/filters/incoming-chat-filter'; import { NoopChatFilter } from '../providers/filters/noop-chat-filter'; import { OutgoingChatFilter } from '../providers/filters/outgoing-chat-filter'; import { PipelineProviderFactory } from '../providers/pipeline-provider.factory'; +import type { PipelineProvider } from '../providers/pipeline-provider.interface'; +import type { ChatFilter } from '../providers/chat-filter.interface'; import { GitHubProvider } from '../providers/pipelines/github.provider'; import { GitLabProvider } from '../providers/pipelines/gitlab.provider'; import { AgentEnvironmentVariablesRepository } from '../repositories/agent-environment-variables.repository'; @@ -132,39 +139,64 @@ import { WorkspaceConfigurationOverridesService } from '../services/workspace-co WorkspaceConfigurationOverridesService, DatabaseRegexIncomingChatFilter, DatabaseRegexOutgoingChatFilter, + DynamicProviderLoaderService, { provide: 'AGENT_PROVIDER_INIT', - useFactory: ( + useFactory: async ( factory: AgentProviderFactory, cursorProvider: CursorAgentProvider, opencodeProvider: OpenCodeAgentProvider, openclawProvider: OpenClawAgentProvider, + dynamicLoader: DynamicProviderLoaderService, ) => { factory.registerProvider(cursorProvider); factory.registerProvider(opencodeProvider); factory.registerProvider(openclawProvider); + await registerDynamicProviders({ + envKey: 'DYNAMIC_AGENT_PROVIDERS', + criticality: 'optional', + register: (provider) => factory.registerProvider(provider), + dynamicLoader, + loggerContext: 'AgentProviderFactory', + }); + return true; }, - inject: [AgentProviderFactory, CursorAgentProvider, OpenCodeAgentProvider, OpenClawAgentProvider], + inject: [ + AgentProviderFactory, + CursorAgentProvider, + OpenCodeAgentProvider, + OpenClawAgentProvider, + DynamicProviderLoaderService, + ], }, { provide: 'PIPELINE_PROVIDER_INIT', - useFactory: ( + useFactory: async ( factory: PipelineProviderFactory, githubProvider: GitHubProvider, gitlabProvider: GitLabProvider, + dynamicLoader: DynamicProviderLoaderService, ) => { factory.registerProvider(githubProvider); factory.registerProvider(gitlabProvider); + await registerDynamicProviders({ + envKey: 'DYNAMIC_PIPELINE_PROVIDERS', + criticality: 'optional', + register: (provider) => factory.registerProvider(provider), + dynamicLoader, + loggerContext: 'PipelineProviderFactory', + }); + return true; }, - inject: [PipelineProviderFactory, GitHubProvider, GitLabProvider], + inject: [PipelineProviderFactory, GitHubProvider, GitLabProvider, DynamicProviderLoaderService], }, { provide: 'CHAT_FILTER_INIT', - useFactory: ( + useFactory: async ( factory: ChatFilterFactory, noopFilter: NoopChatFilter, incomingFilter: IncomingChatFilter, @@ -172,6 +204,7 @@ import { WorkspaceConfigurationOverridesService } from '../services/workspace-co bidirectionalFilter: BidirectionalChatFilter, dbIncoming: DatabaseRegexIncomingChatFilter, dbOutgoing: DatabaseRegexOutgoingChatFilter, + dynamicLoader: DynamicProviderLoaderService, ) => { factory.registerFilter(noopFilter); factory.registerFilter(incomingFilter); @@ -180,6 +213,14 @@ import { WorkspaceConfigurationOverridesService } from '../services/workspace-co factory.registerFilter(dbIncoming); factory.registerFilter(dbOutgoing); + await registerDynamicProviders({ + envKey: 'DYNAMIC_CHAT_FILTERS', + criticality: 'optional', + register: (filter) => factory.registerFilter(filter), + dynamicLoader, + loggerContext: 'ChatFilterFactory', + }); + return true; }, inject: [ @@ -190,6 +231,7 @@ import { WorkspaceConfigurationOverridesService } from '../services/workspace-co BidirectionalChatFilter, DatabaseRegexIncomingChatFilter, DatabaseRegexOutgoingChatFilter, + DynamicProviderLoaderService, ], }, ], diff --git a/libs/domains/decabill/backend/feature-billing-manager/README.md b/libs/domains/decabill/backend/feature-billing-manager/README.md index 79924eb00..a731f5638 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/README.md +++ b/libs/domains/decabill/backend/feature-billing-manager/README.md @@ -69,7 +69,7 @@ When **`AUTHENTICATION_METHOD=api-key`** (or api-key is inferred from **`STATIC_ ## Users Authentication When AUTHENTICATION_METHOD=users, this service uses a local users table identical to the agent-controller schema. -The migration `apps/agenstra/backend-billing-manager/src/migrations/1767101000000_CreateUsersTable.ts` creates the required table. +The users table migration is compiled from `libs/domains/identity/backend/util-auth/src/lib/migrations/1765000000000_CreateUsersTable.ts` into the billing manager deploy artifact. ## Customer Profile @@ -84,7 +84,17 @@ Usage records can be posted to `POST /usage/record` and will be included in invo ## Provider details -`GET /service-types/providers` returns all registered provisioning providers with id, display name, and optional config schema. This is used by the billing console to show a provider dropdown when creating service types and to render provider default config fields when creating/editing service plans. Providers are registered at startup (e.g. Hetzner, DigitalOcean) via `ProviderRegistryService`; add new providers in `BillingModule.onModuleInit()` or by injecting and calling `ProviderRegistryService.register()`. +`GET /service-types/providers` returns all registered provisioning providers with id, display name, and optional config schema. This is used by the billing console to show a provider dropdown when creating service types and to render provider default config fields when creating/editing service plans. Providers are registered at startup (e.g. Hetzner, DigitalOcean) via `ProviderRegistryService`; additional metadata can be loaded from `DYNAMIC_BILLING_PROVIDER_METADATA`. + +**Dynamic provider plugins:** + +- `DYNAMIC_PAYMENT_PROCESSORS` - Comma-separated extra payment processor packages (critical; use with `DYNAMIC_PROVIDERS_FAIL_FAST=true` in production) +- `DYNAMIC_BILLING_PROVIDER_METADATA` - Comma-separated packages exporting `providerMetadata` for the billing UI registry +- `DYNAMIC_PROVIDERS_FAIL_FAST` - When `true`, abort startup if critical dynamic providers fail to load +- `DYNAMIC_PROVIDER_PLUGIN_PATH` - Plugin root for post-build loading (compose default: `/var/lib/forepath/provider-plugins`) +- `DYNAMIC_PROVIDER_PLUGIN_INSTALL` - Startup `npm install` targets into the plugin path + +Plugins can be baked into the billing backend deploy graph or mounted after image build. See `@forepath/shared/backend/util-dynamic-provider-registry` README and `apps/decabill/backend-billing-manager/docker-compose.yaml` (`./provider-plugins` volume). **Config schema shape:** The optional `configSchema` is a JSON-schema-like object with a `properties` map. Each property may include: diff --git a/libs/domains/decabill/backend/feature-billing-manager/project.json b/libs/domains/decabill/backend/feature-billing-manager/project.json index bb648e524..13d07eb99 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/project.json +++ b/libs/domains/decabill/backend/feature-billing-manager/project.json @@ -8,7 +8,8 @@ "identity-backend-util-auth", "identity-backend-feature-auth", "shared-backend-util-crypto", - "shared-backend-util-email" + "shared-backend-util-email", + "shared-backend-util-dynamic-provider-registry" ], "targets": { "test": { diff --git a/libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts b/libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts index f4031b1da..b92202d3f 100644 --- a/libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts +++ b/libs/domains/decabill/backend/feature-billing-manager/src/lib/billing.module.ts @@ -6,6 +6,11 @@ import { UsersRepository, } from '@forepath/identity/backend'; import { EmailService } from '@forepath/shared/backend'; +import { + DynamicProviderLoaderService, + registerDynamicProviderMetadata, + registerDynamicProviders, +} from '@forepath/shared/backend/util-dynamic-provider-registry'; import { Module, OnModuleInit } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -47,6 +52,7 @@ import { UsageRecordEntity } from './entities/usage-record.entity'; import { BillingStatusGateway } from './gateways/billing-status.gateway'; import { TenantUserGuard } from './guards/tenant-user.guard'; import { PaymentProcessorFactory } from './payment-processors/payment-processor.factory'; +import type { PaymentProcessor } from './payment-processors/payment-processor.interface'; import { StripePaymentProcessor } from './payment-processors/processors/stripe-payment.processor'; import { AdminBillNowEnqueueAdapter } from './queue/admin-bill-now-enqueue.adapter'; import { ADMIN_BILL_NOW_ENQUEUE } from './queue/admin-bill-now-enqueue.token'; @@ -366,15 +372,28 @@ const DIGITALOCEAN_CONFIG_SCHEMA: Record = { InvoiceCreationService, PaymentProcessorFactory, StripePaymentProcessor, + DynamicProviderLoaderService, PaymentOrchestrationService, { provide: PAYMENT_PROCESSOR_INIT, - useFactory: (factory: PaymentProcessorFactory, stripe: StripePaymentProcessor) => { + useFactory: async ( + factory: PaymentProcessorFactory, + stripe: StripePaymentProcessor, + dynamicLoader: DynamicProviderLoaderService, + ) => { factory.registerProcessor(stripe); + await registerDynamicProviders({ + envKey: 'DYNAMIC_PAYMENT_PROCESSORS', + criticality: 'critical', + register: (processor) => factory.registerProcessor(processor), + dynamicLoader, + loggerContext: 'PaymentProcessorFactory', + }); + return true; }, - inject: [PaymentProcessorFactory, StripePaymentProcessor], + inject: [PaymentProcessorFactory, StripePaymentProcessor, DynamicProviderLoaderService], }, ProvisioningService, SubscriptionItemServerService, @@ -466,9 +485,12 @@ const DIGITALOCEAN_CONFIG_SCHEMA: Record = { ], }) export class BillingModule implements OnModuleInit { - constructor(private readonly providerRegistry: ProviderRegistryService) {} + constructor( + private readonly providerRegistry: ProviderRegistryService, + private readonly dynamicLoader: DynamicProviderLoaderService, + ) {} - onModuleInit(): void { + async onModuleInit(): Promise { this.providerRegistry.register({ id: 'hetzner', displayName: 'Hetzner Cloud', @@ -479,5 +501,13 @@ export class BillingModule implements OnModuleInit { displayName: 'DigitalOcean', configSchema: DIGITALOCEAN_CONFIG_SCHEMA, }); + + await registerDynamicProviderMetadata({ + envKey: 'DYNAMIC_BILLING_PROVIDER_METADATA', + criticality: 'optional', + register: (metadata) => this.providerRegistry.register(metadata), + dynamicLoader: this.dynamicLoader, + loggerContext: 'ProviderRegistryService', + }); } } diff --git a/libs/domains/shared/backend/index.ts b/libs/domains/shared/backend/index.ts index 9cb4909de..dcb592e0e 100644 --- a/libs/domains/shared/backend/index.ts +++ b/libs/domains/shared/backend/index.ts @@ -4,3 +4,4 @@ export * from './util-email/src'; export * from './util-http-context/src'; export * from './util-queue/src'; export * from './feature-monitoring/src'; +export * from './util-dynamic-provider-registry/src'; diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/.eslintrc.json b/libs/domains/shared/backend/util-dynamic-provider-registry/.eslintrc.json new file mode 100644 index 000000000..b33995654 --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/README.md b/libs/domains/shared/backend/util-dynamic-provider-registry/README.md new file mode 100644 index 000000000..8f886dd53 --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/README.md @@ -0,0 +1,164 @@ +# @forepath/shared/backend/util-dynamic-provider-registry + +Shared utilities for loading and registering dynamic NestJS provider plugins from comma-separated `DYNAMIC_*` environment variables. + +## Dual-source loading + +Provider packages resolve in this order: + +1. **Baked-in** — `createRequire(/package.json).resolve(specifier)` (image build / prune graph) +2. **Plugin path** — lookup under `DYNAMIC_PROVIDER_PLUGIN_PATH` by `package.json` `name` +3. **Fail** — log and skip (or abort when critical + `DYNAMIC_PROVIDERS_FAIL_FAST=true`) + +Baked-in resolution wins when both sources exist. Post-build loading is additive; it does not replace baking plugins into the deploy graph. + +### Baked-in providers + +A package listed in a `DYNAMIC_*` env var can be a runtime dependency of the consuming backend application. Add it to the app deploy graph and run `nx run :prune` so it appears in `dist/apps///package.json` or `workspace_modules/`. + +### Post-build providers (mounted / startup install) + +Without rebuilding the app image, operators can: + +1. Set `DYNAMIC_PROVIDER_PLUGIN_PATH` to an absolute path inside the container (recommended with compose: `/var/lib/forepath/provider-plugins` when the `./provider-plugins` volume is mounted) +2. Mount plugin folders or tarballs into that path, and/or set `DYNAMIC_PROVIDER_PLUGIN_INSTALL` for startup `npm install` +3. Reference packages by **name** in existing `DYNAMIC_*` env vars (e.g. `custom=@forepath/agenstra/backend/provisioning-custom`) +4. Restart the container + +At container startup, `install-provider-plugins.js` runs before `main.js` when `DYNAMIC_PROVIDER_PLUGIN_PATH` is set. + +Plugin packages should declare Nest/host dependencies as **peerDependencies** so they resolve from `/app/node_modules`. + +## Environment variables + +| Variable | Criticality | Purpose | +| ----------------------------------- | ----------- | ------------------------------------------------------------- | +| `DYNAMIC_PROVISIONING_PROVIDERS` | critical | Extra provisioning providers | +| `DYNAMIC_CONTEXT_IMPORT_PROVIDERS` | optional | Extra context import providers | +| `DYNAMIC_AGENT_PROVIDERS` | optional | Extra agent backends | +| `DYNAMIC_PIPELINE_PROVIDERS` | optional | Extra CI/CD providers | +| `DYNAMIC_CHAT_FILTERS` | optional | Extra chat filters | +| `DYNAMIC_PAYMENT_PROCESSORS` | critical | Extra payment processors | +| `DYNAMIC_BILLING_PROVIDER_METADATA` | optional | Extra billing UI provider metadata | +| `DYNAMIC_PROVIDERS_FAIL_FAST` | — | When `true`, critical registries abort startup on load errors | +| `DYNAMIC_PROVIDER_PLUGIN_PATH` | — | Absolute plugin root for post-build loading | +| `DYNAMIC_PROVIDER_PLUGIN_INSTALL` | — | Comma-separated `npm install` targets at startup | + +### Provider config format + +```bash +# alias=@package/specifier (baked-in or plugin-path by package name) +DYNAMIC_PROVISIONING_PROVIDERS=custom=@forepath/agenstra/backend/provisioning-custom + +# PascalCase alias selects a named class export +DYNAMIC_PROVISIONING_PROVIDERS=CustomProvider=@forepath/agenstra/backend/provisioning-custom + +# bare specifier +DYNAMIC_AGENT_PROVIDERS=@forepath/agenstra/backend/agent-custom + +# file: entry (always relative to DYNAMIC_PROVIDER_PLUGIN_PATH) +DYNAMIC_PROVISIONING_PROVIDERS=custom=file:provisioning-custom +``` + +Allowed specifier prefixes: `@forepath/`, `@agenstra/`. Do not combine `file:` with `@forepath/` on the same entry. + +### Startup install format + +```bash +# Registry package (requires .npmrc / auth in image or mounted secret) +DYNAMIC_PROVIDER_PLUGIN_INSTALL=@forepath/agenstra-provisioning-custom@1.2.0 + +# Local tarball or directory under plugin path +DYNAMIC_PROVIDER_PLUGIN_INSTALL=file:/var/lib/forepath/provider-plugins/my-plugin.tgz,file:my-plugin-dir + +# Mixed +DYNAMIC_PROVIDER_PLUGIN_INSTALL=file:foo.tgz,@forepath/bar@2.0.0 +``` + +`file:` install paths must resolve under `DYNAMIC_PROVIDER_PLUGIN_PATH`. Compose mounts `./provider-plugins` read-write by default so startup install can write; add `:ro` only when plugins are pre-copied and `DYNAMIC_PROVIDER_PLUGIN_INSTALL` is unset. + +## Plugin package export contract + +External plugin packages must export one of: + +1. **`createProvider`** (preferred) — `(moduleRef: ModuleRef) => T | Promise` +2. **Named PascalCase class** — via entry alias (`ClassName=@forepath/pkg`) or `package.json`: + +```json +{ + "forepath": { + "providerExport": "CustomProvisioningProvider" + } +} +``` + +Optional: **`providerMetadata`** — `{ id, displayName, configSchema? }` for billing registry. + +Generic `provider` / `Provider` exports are **not** accepted for plugin packages (test fixtures only). + +### Example plugin `index.ts` + +```typescript +import type { ModuleRef } from '@nestjs/core'; + +export async function createProvider(moduleRef: ModuleRef) { + return moduleRef.create(CustomProvisioningProvider); +} + +export { CustomProvisioningProvider } from './custom-provisioning.provider'; +export { CUSTOM_PROVIDER_METADATA as providerMetadata } from './metadata'; +``` + +## Startup error policy + +| Criticality | `DYNAMIC_PROVIDERS_FAIL_FAST` | On error | +| ----------- | ----------------------------- | ------------------ | +| optional | any | Log and skip entry | +| critical | unset / `false` | Log and skip entry | +| critical | `true` | Abort startup | + +**Production recommendation:** set `DYNAMIC_PROVIDERS_FAIL_FAST=true` when `DYNAMIC_PROVISIONING_PROVIDERS` or `DYNAMIC_PAYMENT_PROCESSORS` is non-empty. + +## Usage in NestJS modules + +Static registrations remain unchanged. Append dynamic registration after static entries: + +```typescript +import { + DynamicProviderLoaderService, + registerDynamicProviders, +} from '@forepath/shared/backend/util-dynamic-provider-registry'; + +{ + provide: 'PROVISIONING_PROVIDERS', + useFactory: async (factory, hetzner, digitalOcean, dynamicLoader) => { + factory.registerProvider(hetzner); + factory.registerProvider(digitalOcean); + + await registerDynamicProviders({ + envKey: 'DYNAMIC_PROVISIONING_PROVIDERS', + criticality: 'critical', + register: (provider) => factory.registerProvider(provider), + dynamicLoader, + }); + + return factory; + }, + inject: [ProvisioningProviderFactory, HetznerProvider, DigitalOceanProvider, DynamicProviderLoaderService], +} +``` + +## Public API + +- `parseProviderPackageSpec(raw)` — parse env string +- `parseProviderPluginInstallSpec(raw)` — parse startup install string +- `assertRuntimeDependency(specifier, options)` — baked-in runtime dependency gate +- `assertPathUnderPluginRoot(candidatePath, pluginRoot)` — path traversal guard +- `buildPluginPathIndex(pluginPath)` — index mounted packages by `package.json` name +- `resolveProviderLoadTarget(entry, options)` — baked-in first, plugin-path fallback +- `loadProviderModule(entry, options)` — load from baked-in or plugin path +- `resolveProviderExport(module, options)` — plugin export contract resolution +- `installProviderPluginsFromEnv(env?)` — startup `npm install` into plugin path +- `registerDynamicProviders(options)` — register instances from env +- `registerDynamicProviderMetadata(options)` — register billing metadata from env +- `DynamicProviderLoaderService` — Nest injectable loader diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/jest.config.cts b/libs/domains/shared/backend/util-dynamic-provider-registry/jest.config.cts new file mode 100644 index 000000000..440b7c796 --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/jest.config.cts @@ -0,0 +1,13 @@ +module.exports = { + displayName: 'shared-backend-util-dynamic-provider-registry', + preset: '../../../../../jest.preset.cjs', + testEnvironment: 'node', + globalSetup: '/src/test-fixtures/global-setup.cjs', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + transformIgnorePatterns: ['/node_modules/', '/src/test-fixtures/'], + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: + '../../../../../coverage/libs/domains/shared/backend/util-dynamic-provider-registry', +}; diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/package.json b/libs/domains/shared/backend/util-dynamic-provider-registry/package.json new file mode 100644 index 000000000..9e9a304cd --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/package.json @@ -0,0 +1,13 @@ +{ + "name": "@forepath/shared/backend/util-dynamic-provider-registry", + "version": "0.0.1", + "private": true, + "type": "commonjs", + "main": "./src/index.js", + "types": "./src/index.d.ts", + "dependencies": { + "tslib": "^2.3.0", + "@nestjs/common": "11.1.6", + "@nestjs/core": "11.1.6" + } +} diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/project.json b/libs/domains/shared/backend/util-dynamic-provider-registry/project.json new file mode 100644 index 000000000..d205c0b8c --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/project.json @@ -0,0 +1,16 @@ +{ + "name": "shared-backend-util-dynamic-provider-registry", + "$schema": "../../../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/domains/shared/backend/util-dynamic-provider-registry/src", + "projectType": "library", + "tags": ["domain:shared", "scope:backend", "type:util"], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/domains/shared/backend/util-dynamic-provider-registry/jest.config.cts" + } + } + } +} diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/index.ts b/libs/domains/shared/backend/util-dynamic-provider-registry/src/index.ts new file mode 100644 index 000000000..1101c42dd --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/index.ts @@ -0,0 +1,12 @@ +export * from './lib/types'; +export * from './lib/parse-provider-package-spec'; +export * from './lib/assert-runtime-dependency'; +export * from './lib/assert-plugin-path'; +export * from './lib/plugin-path-index'; +export * from './lib/resolve-provider-load-target'; +export * from './lib/load-provider-module'; +export * from './lib/resolve-provider-export'; +export * from './lib/startup-error-policy'; +export * from './lib/install-provider-plugins'; +export * from './lib/dynamic-provider-loader.service'; +export * from './lib/register-dynamic-providers'; diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/assert-plugin-path.spec.ts b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/assert-plugin-path.spec.ts new file mode 100644 index 000000000..762d5d91f --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/assert-plugin-path.spec.ts @@ -0,0 +1,15 @@ +import { join } from 'node:path'; + +import { PluginPathSecurityError, assertPathUnderPluginRoot } from './assert-plugin-path'; + +const pluginRoot = join(__dirname, '../test-fixtures/plugin-root'); + +describe('assertPathUnderPluginRoot', () => { + it('allows relative paths under plugin root', () => { + expect(assertPathUnderPluginRoot('mounted-plugin-fixture', pluginRoot)).toContain('mounted-plugin-fixture'); + }); + + it('rejects traversal outside plugin root', () => { + expect(() => assertPathUnderPluginRoot('../runtime-provider-fixture', pluginRoot)).toThrow(PluginPathSecurityError); + }); +}); diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/assert-plugin-path.ts b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/assert-plugin-path.ts new file mode 100644 index 000000000..9430d24c9 --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/assert-plugin-path.ts @@ -0,0 +1,63 @@ +import { realpathSync } from 'node:fs'; +import { resolve } from 'node:path'; + +export class PluginPathSecurityError extends Error { + constructor( + message: string, + readonly candidatePath: string, + readonly pluginRoot: string, + ) { + super(message); + this.name = 'PluginPathSecurityError'; + } +} + +/** + * Resolves candidatePath and ensures it stays under pluginRoot (blocks traversal). + */ +export function resolvePathUnderPluginRoot(candidatePath: string, pluginRoot: string): string { + const resolvedRoot = resolve(pluginRoot); + const resolvedCandidate = candidatePath.startsWith('/') + ? resolve(candidatePath) + : resolve(resolvedRoot, candidatePath); + + if (!resolvedCandidate.startsWith(`${resolvedRoot}/`) && resolvedCandidate !== resolvedRoot) { + throw new PluginPathSecurityError( + `Path '${candidatePath}' resolves outside plugin root '${pluginRoot}'`, + candidatePath, + pluginRoot, + ); + } + + try { + return realpathSync(resolvedCandidate); + } catch { + return resolvedCandidate; + } +} + +export function assertPathUnderPluginRoot(candidatePath: string, pluginRoot: string): string { + const resolved = resolvePathUnderPluginRoot(candidatePath, pluginRoot); + const resolvedRoot = resolve(pluginRoot); + + try { + const realCandidate = realpathSync(resolved); + const realRoot = realpathSync(resolvedRoot); + + if (!realCandidate.startsWith(`${realRoot}/`) && realCandidate !== realRoot) { + throw new PluginPathSecurityError( + `Real path '${realCandidate}' escapes plugin root '${realRoot}'`, + candidatePath, + pluginRoot, + ); + } + + return realCandidate; + } catch (error) { + if (error instanceof PluginPathSecurityError) { + throw error; + } + + return resolved; + } +} diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/assert-runtime-dependency.spec.ts b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/assert-runtime-dependency.spec.ts new file mode 100644 index 000000000..31dc2eec3 --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/assert-runtime-dependency.spec.ts @@ -0,0 +1,36 @@ +import { join } from 'node:path'; + +import { + assertAllowlistedSpecifier, + assertRuntimeDependency, + RuntimeDependencyError, + SpecifierAllowlistError, +} from './assert-runtime-dependency'; + +const runtimeFixtureAppRoot = join(__dirname, '../test-fixtures/app-root-fixture'); + +describe('assertRuntimeDependency', () => { + it('passes when specifier resolves from fixture app root', () => { + expect(() => + assertRuntimeDependency('@forepath/test/runtime-provider-fixture', { + appRoot: runtimeFixtureAppRoot, + envKey: 'DYNAMIC_TEST', + allowlistPrefixes: ['@forepath/'], + }), + ).not.toThrow(); + }); + + it('fails when specifier is not in app runtime dependency graph', () => { + expect(() => + assertRuntimeDependency('@forepath/missing/runtime-only-tsconfig', { + appRoot: runtimeFixtureAppRoot, + envKey: 'DYNAMIC_TEST', + allowlistPrefixes: ['@forepath/'], + }), + ).toThrow(RuntimeDependencyError); + }); + + it('rejects non-allowlisted specifiers', () => { + expect(() => assertAllowlistedSpecifier('@evil/pkg')).toThrow(SpecifierAllowlistError); + }); +}); diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/assert-runtime-dependency.ts b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/assert-runtime-dependency.ts new file mode 100644 index 000000000..2207e0ad2 --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/assert-runtime-dependency.ts @@ -0,0 +1,68 @@ +import { createRequire } from 'node:module'; +import { join } from 'node:path'; + +import { DEFAULT_SPECIFIER_ALLOWLIST_PREFIXES } from './types'; +import type { AssertRuntimeDependencyOptions } from './types'; + +export class RuntimeDependencyError extends Error { + constructor( + message: string, + readonly specifier: string, + readonly appRoot: string, + readonly envKey?: string, + ) { + super(message); + this.name = 'RuntimeDependencyError'; + } +} + +export class SpecifierAllowlistError extends Error { + constructor( + message: string, + readonly specifier: string, + ) { + super(message); + this.name = 'SpecifierAllowlistError'; + } +} + +/** + * Hard gate: the specifier must be allowlisted and resolvable from the app root package.json. + */ +export function assertRuntimeDependency(specifier: string, options: AssertRuntimeDependencyOptions = {}): void { + const appRoot = options.appRoot ?? process.cwd(); + const envKey = options.envKey; + const allowlistPrefixes = options.allowlistPrefixes ?? DEFAULT_SPECIFIER_ALLOWLIST_PREFIXES; + + assertAllowlistedSpecifier(specifier, allowlistPrefixes); + + try { + const require = createRequire(join(appRoot, 'package.json')); + + require.resolve(specifier); + } catch (cause) { + const envSuffix = envKey ? ` (env: ${envKey})` : ''; + + throw new RuntimeDependencyError( + `Package '${specifier}' is not a runtime dependency of app root '${appRoot}'${envSuffix}. ` + + 'Add it to the consuming backend application deploy graph before listing it in a DYNAMIC_* env var.', + specifier, + appRoot, + envKey, + ); + } +} + +export function assertAllowlistedSpecifier( + specifier: string, + allowlistPrefixes: readonly string[] = DEFAULT_SPECIFIER_ALLOWLIST_PREFIXES, +): void { + const allowed = allowlistPrefixes.some((prefix) => specifier.startsWith(prefix)); + + if (!allowed) { + throw new SpecifierAllowlistError( + `Package specifier '${specifier}' is not allowlisted. Allowed prefixes: ${allowlistPrefixes.join(', ')}`, + specifier, + ); + } +} diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/dynamic-provider-loader.service.spec.ts b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/dynamic-provider-loader.service.spec.ts new file mode 100644 index 000000000..33a59c8f7 --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/dynamic-provider-loader.service.spec.ts @@ -0,0 +1,119 @@ +import { Test } from '@nestjs/testing'; +import { join } from 'node:path'; + +import { ProviderLoadTargetError } from './resolve-provider-load-target'; +import { DynamicProviderLoaderService } from './dynamic-provider-loader.service'; +import { DYNAMIC_PROVIDERS_FAIL_FAST_ENV } from './types'; + +const runtimeFixtureAppRoot = join(__dirname, '../test-fixtures/app-root-fixture'); +const pluginFixtureRoot = join(__dirname, '../test-fixtures/plugin-root'); + +describe('DynamicProviderLoaderService', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('loads plugin instances from baked-in runtime dependencies', async () => { + process.env['DYNAMIC_TEST_PROVIDERS'] = '@forepath/test/runtime-provider-fixture'; + + const moduleRef = await Test.createTestingModule({ + providers: [DynamicProviderLoaderService], + }).compile(); + const loader = moduleRef.get(DynamicProviderLoaderService); + + const instances = await loader.loadInstances<{ getType(): string }>('DYNAMIC_TEST_PROVIDERS', 'optional', { + appRoot: runtimeFixtureAppRoot, + }); + + expect(instances).toHaveLength(1); + expect(instances[0]?.getType()).toBe('fixture-provider'); + }); + + it('loads plugin instances from mounted plugin path fallback', async () => { + process.env['DYNAMIC_TEST_PROVIDERS'] = '@forepath/test/mounted-plugin-fixture'; + + const moduleRef = await Test.createTestingModule({ + providers: [DynamicProviderLoaderService], + }).compile(); + const loader = moduleRef.get(DynamicProviderLoaderService); + + const instances = await loader.loadInstances<{ getType(): string }>('DYNAMIC_TEST_PROVIDERS', 'optional', { + appRoot: runtimeFixtureAppRoot, + pluginPath: pluginFixtureRoot, + }); + + expect(instances).toHaveLength(1); + expect(instances[0]?.getType()).toBe('mounted-fixture-provider'); + }); + + it('loads file: plugin entries from mounted plugin path', async () => { + process.env['DYNAMIC_TEST_PROVIDERS'] = 'file:mounted-plugin-fixture'; + + const moduleRef = await Test.createTestingModule({ + providers: [DynamicProviderLoaderService], + }).compile(); + const loader = moduleRef.get(DynamicProviderLoaderService); + + const instances = await loader.loadInstances<{ getType(): string }>('DYNAMIC_TEST_PROVIDERS', 'optional', { + appRoot: runtimeFixtureAppRoot, + pluginPath: pluginFixtureRoot, + }); + + expect(instances).toHaveLength(1); + expect(instances[0]?.getType()).toBe('mounted-fixture-provider'); + }); + + it('skips unresolved optional entries permissively', async () => { + process.env['DYNAMIC_TEST_PROVIDERS'] = '@forepath/missing/package'; + + const moduleRef = await Test.createTestingModule({ + providers: [DynamicProviderLoaderService], + }).compile(); + const loader = moduleRef.get(DynamicProviderLoaderService); + + const instances = await loader.loadInstances('DYNAMIC_TEST_PROVIDERS', 'optional', { + appRoot: runtimeFixtureAppRoot, + pluginPath: pluginFixtureRoot, + }); + + expect(instances).toEqual([]); + }); + + it('throws for critical entries when fail-fast is enabled', async () => { + process.env['DYNAMIC_TEST_PROVIDERS'] = '@forepath/missing/package'; + process.env[DYNAMIC_PROVIDERS_FAIL_FAST_ENV] = 'true'; + + const moduleRef = await Test.createTestingModule({ + providers: [DynamicProviderLoaderService], + }).compile(); + const loader = moduleRef.get(DynamicProviderLoaderService); + + await expect( + loader.loadInstances('DYNAMIC_TEST_PROVIDERS', 'critical', { + appRoot: runtimeFixtureAppRoot, + pluginPath: pluginFixtureRoot, + }), + ).rejects.toBeInstanceOf(ProviderLoadTargetError); + }); + + it('loads providerMetadata exports', async () => { + process.env['DYNAMIC_TEST_METADATA'] = '@forepath/test/runtime-provider-fixture'; + + const moduleRef = await Test.createTestingModule({ + providers: [DynamicProviderLoaderService], + }).compile(); + const loader = moduleRef.get(DynamicProviderLoaderService); + + const metadata = await loader.loadMetadata('DYNAMIC_TEST_METADATA', 'optional', { + appRoot: runtimeFixtureAppRoot, + }); + + expect(metadata).toEqual([{ id: 'fixture-provider', displayName: 'Fixture Provider' }]); + }); +}); diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/dynamic-provider-loader.service.ts b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/dynamic-provider-loader.service.ts new file mode 100644 index 000000000..edcf06fed --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/dynamic-provider-loader.service.ts @@ -0,0 +1,132 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; + +import { readPluginPathFromEnv } from './install-provider-plugins'; +import { loadProviderModule } from './load-provider-module'; +import { parseProviderPackageSpec } from './parse-provider-package-spec'; +import { resolveProviderLoadTarget } from './resolve-provider-load-target'; +import { instantiateResolvedProvider, resolveProviderExport, resolveProviderMetadata } from './resolve-provider-export'; +import { handleDynamicProviderError } from './startup-error-policy'; +import type { + LoadProviderModuleOptions, + ProviderMetadataRecord, + ProviderPackageEntry, + RegistryCriticality, +} from './types'; + +export interface DynamicProviderLoaderOptions { + appRoot?: string; + pluginPath?: string; + allowTestFixtureExports?: boolean; +} + +@Injectable() +export class DynamicProviderLoaderService { + private readonly logger = new Logger(DynamicProviderLoaderService.name); + + constructor(private readonly moduleRef: ModuleRef) {} + + async loadInstances( + envKey: string, + criticality: RegistryCriticality, + options: DynamicProviderLoaderOptions & { failFast?: boolean } = {}, + ): Promise { + const entries = parseProviderPackageSpec(process.env[envKey]); + const instances: T[] = []; + const loaderOptions = this.buildLoaderOptions(options); + + for (const entry of entries) { + try { + const instance = await this.loadInstance(entry, envKey, loaderOptions); + + instances.push(instance); + } catch (error) { + handleDynamicProviderError(error, { + criticality, + failFast: options.failFast, + envKey, + entryLabel: formatEntryLabel(entry), + onPermissive: (message, permissiveError) => { + this.logger.error(message, permissiveError instanceof Error ? permissiveError.stack : undefined); + }, + }); + } + } + + return instances; + } + + async loadMetadata( + envKey: string, + criticality: RegistryCriticality, + options: DynamicProviderLoaderOptions & { failFast?: boolean } = {}, + ): Promise { + const entries = parseProviderPackageSpec(process.env[envKey]); + const metadataRecords: ProviderMetadataRecord[] = []; + const loaderOptions = this.buildLoaderOptions(options); + + for (const entry of entries) { + try { + const targetOptions = { ...loaderOptions, envKey }; + const loadTarget = resolveProviderLoadTarget(entry, targetOptions); + const module = await loadProviderModule(entry, targetOptions); + const metadata = resolveProviderMetadata(module); + + if (!metadata) { + throw new Error(`Provider package '${loadTarget.specifier}' does not export providerMetadata`); + } + + metadataRecords.push(metadata); + } catch (error) { + handleDynamicProviderError(error, { + criticality, + failFast: options.failFast, + envKey, + entryLabel: formatEntryLabel(entry), + onPermissive: (message, permissiveError) => { + this.logger.error(message, permissiveError instanceof Error ? permissiveError.stack : undefined); + }, + }); + } + } + + return metadataRecords; + } + + private async loadInstance( + entry: ProviderPackageEntry, + envKey: string, + options: DynamicProviderLoaderOptions, + ): Promise { + const targetOptions: LoadProviderModuleOptions = { ...options, envKey }; + const loadTarget = resolveProviderLoadTarget(entry, targetOptions); + const module = await loadProviderModule(entry, targetOptions); + const resolved = resolveProviderExport(module, { + entry, + loadTarget, + allowTestFixtureExports: options.allowTestFixtureExports, + }); + + return await instantiateResolvedProvider(resolved, this.moduleRef); + } + + private buildLoaderOptions(options: DynamicProviderLoaderOptions): DynamicProviderLoaderOptions { + return { + appRoot: options.appRoot, + pluginPath: options.pluginPath ?? readPluginPathFromEnv(), + allowTestFixtureExports: options.allowTestFixtureExports, + }; + } +} + +function formatEntryLabel(entry: ProviderPackageEntry): string { + if (entry.alias) { + return `${entry.alias}=${entry.specifier}`; + } + + if (entry.classExport) { + return `${entry.classExport}=${entry.specifier}`; + } + + return entry.specifier; +} diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/install-provider-plugins.spec.ts b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/install-provider-plugins.spec.ts new file mode 100644 index 000000000..5e42eeccb --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/install-provider-plugins.spec.ts @@ -0,0 +1,100 @@ +import { spawn } from 'node:child_process'; +import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; + +import { DYNAMIC_PROVIDER_PLUGIN_INSTALL_ENV, DYNAMIC_PROVIDER_PLUGIN_PATH_ENV } from './types'; +import { installProviderPluginsFromEnv } from './install-provider-plugins'; + +jest.mock('node:child_process', () => ({ + spawn: jest.fn(), +})); + +const spawnMock = spawn as jest.MockedFunction; + +describe('installProviderPluginsFromEnv', () => { + const tempRoot = join(__dirname, '../test-fixtures/temp-install-root'); + const mountedFixture = join(__dirname, '../test-fixtures/mounted-plugin-fixture'); + const originalEnv = { ...process.env }; + + beforeEach(() => { + mkdirSync(tempRoot, { recursive: true }); + const linkedMounted = join(tempRoot, 'mounted-plugin-fixture'); + + if (existsSync(linkedMounted)) { + rmSync(linkedMounted, { recursive: true, force: true }); + } + + cpSync(mountedFixture, linkedMounted, { recursive: true }); + + process.env = { ...originalEnv }; + spawnMock.mockReset(); + }); + + afterEach(() => { + process.env = originalEnv; + + if (existsSync(tempRoot)) { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('no-ops when plugin path is unset', async () => { + delete process.env[DYNAMIC_PROVIDER_PLUGIN_PATH_ENV]; + + await expect(installProviderPluginsFromEnv(process.env)).resolves.toBeUndefined(); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('creates plugin path without install spec', async () => { + process.env[DYNAMIC_PROVIDER_PLUGIN_PATH_ENV] = tempRoot; + delete process.env[DYNAMIC_PROVIDER_PLUGIN_INSTALL_ENV]; + + await installProviderPluginsFromEnv(process.env); + + expect(existsSync(tempRoot)).toBe(true); + expect(spawnMock).not.toHaveBeenCalled(); + }); + + it('runs npm install for registry and file targets', async () => { + process.env[DYNAMIC_PROVIDER_PLUGIN_PATH_ENV] = tempRoot; + process.env[DYNAMIC_PROVIDER_PLUGIN_INSTALL_ENV] = + 'file:mounted-plugin-fixture,@forepath/test/mounted-plugin-fixture@0.0.1'; + + spawnMock.mockImplementation(() => { + return { + on: (event: string, callback: (code: number) => void) => { + if (event === 'close') { + callback(0); + } + }, + } as never; + }); + + await installProviderPluginsFromEnv(process.env); + + expect(spawnMock).toHaveBeenCalledTimes(2); + expect(spawnMock.mock.calls[0]?.[1]).toEqual( + expect.arrayContaining(['install', expect.stringMatching(/^file:/), '--prefix', tempRoot]), + ); + expect(spawnMock.mock.calls[1]?.[1]).toEqual( + expect.arrayContaining(['install', '@forepath/test/mounted-plugin-fixture@0.0.1', '--prefix', tempRoot]), + ); + }); + + it('throws when npm install fails', async () => { + process.env[DYNAMIC_PROVIDER_PLUGIN_PATH_ENV] = tempRoot; + process.env[DYNAMIC_PROVIDER_PLUGIN_INSTALL_ENV] = '@forepath/broken@1.0.0'; + + spawnMock.mockImplementation(() => { + return { + on: (event: string, callback: (code: number) => void) => { + if (event === 'close') { + callback(1); + } + }, + } as never; + }); + + await expect(installProviderPluginsFromEnv(process.env)).rejects.toThrow('npm install'); + }); +}); diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/install-provider-plugins.ts b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/install-provider-plugins.ts new file mode 100644 index 000000000..51319e186 --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/install-provider-plugins.ts @@ -0,0 +1,127 @@ +import { spawn } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +import { assertPathUnderPluginRoot } from './assert-plugin-path'; +import { parseProviderPluginInstallSpec } from './parse-provider-package-spec'; +import { DYNAMIC_PROVIDER_PLUGIN_INSTALL_ENV, DYNAMIC_PROVIDER_PLUGIN_PATH_ENV } from './types'; + +const PLUGIN_MANIFEST_NAME = 'forepath-dynamic-provider-plugins'; + +export class ProviderPluginInstallError extends Error { + constructor(message: string) { + super(message); + this.name = 'ProviderPluginInstallError'; + } +} + +export async function installProviderPluginsFromEnv(env: NodeJS.ProcessEnv = process.env): Promise { + const pluginPath = env[DYNAMIC_PROVIDER_PLUGIN_PATH_ENV]?.trim(); + + if (!pluginPath) { + return; + } + + const installSpec = env[DYNAMIC_PROVIDER_PLUGIN_INSTALL_ENV]; + + if (!installSpec?.trim()) { + mkdirSync(pluginPath, { recursive: true }); + + return; + } + + mkdirSync(pluginPath, { recursive: true }); + ensurePluginManifest(pluginPath); + + const installTargets = parseProviderPluginInstallSpec(installSpec); + + for (const target of installTargets) { + await installSingleTarget(target, pluginPath); + } +} + +function ensurePluginManifest(pluginPath: string): void { + const manifestPath = join(pluginPath, 'package.json'); + + if (existsSync(manifestPath)) { + return; + } + + writeFileSync( + manifestPath, + `${JSON.stringify( + { + name: PLUGIN_MANIFEST_NAME, + private: true, + version: '0.0.0', + }, + null, + 2, + )}\n`, + ); +} + +async function installSingleTarget(target: string, pluginPath: string): Promise { + const npmArg = resolveInstallTarget(target, pluginPath); + + await runNpmInstall(pluginPath, npmArg); +} + +function resolveInstallTarget(target: string, pluginPath: string): string { + if (target.startsWith('file:')) { + const rawPath = target.slice('file:'.length).trim(); + + if (!rawPath) { + throw new ProviderPluginInstallError(`Invalid install target '${target}': missing path after 'file:'`); + } + + const absolutePath = assertPathUnderPluginRoot(rawPath, pluginPath); + + return `file:${absolutePath}`; + } + + return target; +} + +function runNpmInstall(pluginPath: string, packageSpec: string): Promise { + return new Promise((resolvePromise, reject) => { + const child = spawn('npm', ['install', packageSpec, '--prefix', pluginPath, '--no-save', '--omit=dev'], { + stdio: 'inherit', + env: process.env, + }); + + child.on('error', (error) => { + reject(new ProviderPluginInstallError(`Failed to spawn npm install: ${error.message}`)); + }); + + child.on('close', (code) => { + if (code === 0) { + resolvePromise(); + + return; + } + + reject( + new ProviderPluginInstallError( + `npm install ${packageSpec} --prefix ${pluginPath} failed with exit code ${code ?? 'unknown'}`, + ), + ); + }); + }); +} + +export function readPluginPathFromEnv(env: NodeJS.ProcessEnv = process.env): string | undefined { + const pluginPath = env[DYNAMIC_PROVIDER_PLUGIN_PATH_ENV]?.trim(); + + return pluginPath ? resolve(pluginPath) : undefined; +} + +export function readPluginManifest(pluginPath: string): { name: string } | undefined { + const manifestPath = join(pluginPath, 'package.json'); + + if (!existsSync(manifestPath)) { + return undefined; + } + + return JSON.parse(readFileSync(manifestPath, 'utf8')) as { name: string }; +} diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/load-provider-module.ts b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/load-provider-module.ts new file mode 100644 index 000000000..38aa79140 --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/load-provider-module.ts @@ -0,0 +1,104 @@ +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { readFileSync } from 'node:fs'; + +import { resolveProviderLoadTarget } from './resolve-provider-load-target'; +import type { LoadProviderModuleOptions, ProviderLoadTarget, ProviderPackageEntry } from './types'; + +export class ProviderModuleLoadError extends Error { + constructor( + message: string, + readonly entry: ProviderPackageEntry, + readonly envKey?: string, + ) { + super(message); + this.name = 'ProviderModuleLoadError'; + } +} + +/** + * Resolves load target (baked-in or plugin-path), then loads the module. + */ +export async function loadProviderModule( + entry: ProviderPackageEntry, + options: LoadProviderModuleOptions = {}, +): Promise> { + const envKey = options.envKey; + const target = resolveProviderLoadTarget(entry, options); + + try { + if (target.source === 'baked-in') { + return await loadBakedInModule(target); + } + + return await loadPluginPathModule(target); + } catch (error) { + throw new ProviderModuleLoadError( + `Failed to load provider package '${target.specifier}'${envKey ? ` (env: ${envKey})` : ''}: ` + + `${error instanceof Error ? error.message : String(error)}`, + entry, + envKey, + ); + } +} + +async function loadBakedInModule(target: ProviderLoadTarget): Promise> { + try { + const loaded = await import(target.specifier); + + return loaded as Record; + } catch { + const require = createRequire(target.packageJsonPath); + const required = require(target.mainPath) as Record; + + return required; + } +} + +async function loadPluginPathModule(target: ProviderLoadTarget): Promise> { + try { + const loaded = await import(pathToFileURL(target.mainPath).href); + + return loaded as Record; + } catch { + const require = createRequire(target.packageJsonPath); + const required = require(target.mainPath) as Record; + + return required; + } +} + +/** + * Reads optional `forepath.providerExport` from the resolved package manifest. + */ +export function readProviderExportHint( + specifier: string, + appRoot: string = process.cwd(), + loadTarget?: ProviderLoadTarget, +): string | undefined { + const packageJsonPath = loadTarget?.packageJsonPath; + + if (packageJsonPath) { + try { + const manifest = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as { + forepath?: { providerExport?: string }; + }; + + return manifest.forepath?.providerExport; + } catch { + return undefined; + } + } + + try { + const require = createRequire(`${appRoot}/package.json`); + const resolvedPackageJsonPath = require.resolve(`${specifier}/package.json`); + const manifest = JSON.parse(readFileSync(resolvedPackageJsonPath, 'utf8')) as { + forepath?: { providerExport?: string }; + }; + + return manifest.forepath?.providerExport; + } catch { + return undefined; + } +} diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/parse-provider-package-spec.spec.ts b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/parse-provider-package-spec.spec.ts new file mode 100644 index 000000000..3cae138ae --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/parse-provider-package-spec.spec.ts @@ -0,0 +1,64 @@ +import { parseProviderPackageSpec, parseProviderPluginInstallSpec } from './parse-provider-package-spec'; + +describe('parseProviderPackageSpec', () => { + it('returns empty array for undefined or blank input', () => { + expect(parseProviderPackageSpec(undefined)).toEqual([]); + expect(parseProviderPackageSpec(' ')).toEqual([]); + }); + + it('parses bare specifier entries', () => { + expect(parseProviderPackageSpec('@forepath/foo/bar')).toEqual([{ specifier: '@forepath/foo/bar' }]); + }); + + it('parses alias and class export entries', () => { + expect( + parseProviderPackageSpec('custom=@forepath/foo/bar,CustomProvider=@forepath/foo/baz,@forepath/other'), + ).toEqual([ + { alias: 'custom', specifier: '@forepath/foo/bar' }, + { classExport: 'CustomProvider', specifier: '@forepath/foo/baz' }, + { specifier: '@forepath/other' }, + ]); + }); + + it('throws when entry has empty specifier', () => { + expect(() => parseProviderPackageSpec('broken=')).toThrow( + "Invalid dynamic provider entry 'broken=': missing value after '='", + ); + }); +}); + +describe('parseProviderPackageSpec file entries', () => { + it('parses bare file: entries', () => { + expect(parseProviderPackageSpec('file:mounted-plugin-fixture')).toEqual([ + { + specifier: 'file:mounted-plugin-fixture', + pluginRelativePath: 'mounted-plugin-fixture', + }, + ]); + }); + + it('parses alias=file entries', () => { + expect(parseProviderPackageSpec('custom=file:mounted-plugin-fixture')).toEqual([ + { + alias: 'custom', + specifier: 'file:mounted-plugin-fixture', + pluginRelativePath: 'mounted-plugin-fixture', + }, + ]); + }); + + it('rejects file: combined with package-style value', () => { + expect(() => parseProviderPackageSpec('file:@forepath/foo')).toThrow( + "'file:' entries cannot use package specifiers", + ); + }); +}); + +describe('parseProviderPluginInstallSpec', () => { + it('parses comma-separated install targets', () => { + expect(parseProviderPluginInstallSpec('file:foo.tgz,@forepath/bar@1.0.0')).toEqual([ + 'file:foo.tgz', + '@forepath/bar@1.0.0', + ]); + }); +}); diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/parse-provider-package-spec.ts b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/parse-provider-package-spec.ts new file mode 100644 index 000000000..d3e752b6d --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/parse-provider-package-spec.ts @@ -0,0 +1,82 @@ +import type { ProviderPackageEntry } from './types'; + +const PASCAL_CASE_CLASS_NAME = /^[A-Z][A-Za-z0-9]*$/; +const FILE_SPECIFIER_PREFIX = 'file:'; + +export function isFilePluginEntry(entry: ProviderPackageEntry): boolean { + return entry.pluginRelativePath !== undefined; +} + +/** + * Parses a comma-separated dynamic provider spec from env. + * Supports `alias=@pkg`, `ClassName=@pkg`, bare `@pkg`, and `file:relative-dir` entries. + */ +export function parseProviderPackageSpec(raw: string | undefined): ProviderPackageEntry[] { + if (!raw?.trim()) { + return []; + } + + return raw + .split(',') + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0) + .map(parseProviderPackageEntry); +} + +function parseProviderPackageEntry(segment: string): ProviderPackageEntry { + const equalsIndex = segment.indexOf('='); + + if (equalsIndex === -1) { + return parseRightHandSide(segment); + } + + const left = segment.slice(0, equalsIndex).trim(); + const right = segment.slice(equalsIndex + 1).trim(); + + if (!right) { + throw new Error(`Invalid dynamic provider entry '${segment}': missing value after '='`); + } + + const parsedRight = parseRightHandSide(right); + + if (PASCAL_CASE_CLASS_NAME.test(left)) { + return { ...parsedRight, classExport: left }; + } + + return { ...parsedRight, alias: left || undefined }; +} + +function parseRightHandSide(value: string): ProviderPackageEntry { + if (value.startsWith(FILE_SPECIFIER_PREFIX)) { + const pluginRelativePath = value.slice(FILE_SPECIFIER_PREFIX.length).trim(); + + if (!pluginRelativePath) { + throw new Error(`Invalid dynamic provider entry '${value}': missing path after 'file:'`); + } + + if (pluginRelativePath.startsWith('@')) { + throw new Error(`Invalid dynamic provider entry '${value}': 'file:' entries cannot use package specifiers`); + } + + return { + specifier: value, + pluginRelativePath, + }; + } + + return { specifier: value }; +} + +/** + * Parses comma-separated install targets for DYNAMIC_PROVIDER_PLUGIN_INSTALL. + */ +export function parseProviderPluginInstallSpec(raw: string | undefined): string[] { + if (!raw?.trim()) { + return []; + } + + return raw + .split(',') + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); +} diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/plugin-path-index.spec.ts b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/plugin-path-index.spec.ts new file mode 100644 index 000000000..e03aca47a --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/plugin-path-index.spec.ts @@ -0,0 +1,20 @@ +import { join } from 'node:path'; + +import { buildPluginPathIndex, resetPluginPathIndexCache } from './plugin-path-index'; + +const pluginRoot = join(__dirname, '../test-fixtures/plugin-root'); + +describe('buildPluginPathIndex', () => { + beforeEach(() => { + resetPluginPathIndexCache(); + }); + + it('indexes mounted plugin packages by package.json name', () => { + const index = buildPluginPathIndex(pluginRoot, ['@forepath/']); + + expect(index.get('@forepath/test/mounted-plugin-fixture')).toMatchObject({ + source: 'plugin-path', + specifier: '@forepath/test/mounted-plugin-fixture', + }); + }); +}); diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/plugin-path-index.ts b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/plugin-path-index.ts new file mode 100644 index 000000000..6eb255379 --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/plugin-path-index.ts @@ -0,0 +1,196 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { assertAllowlistedSpecifier } from './assert-runtime-dependency'; +import { assertPathUnderPluginRoot } from './assert-plugin-path'; +import type { ProviderLoadTarget } from './types'; +import { DEFAULT_SPECIFIER_ALLOWLIST_PREFIXES } from './types'; + +interface PackageManifest { + name?: string; + main?: string; +} + +let cachedPluginPath: string | undefined; +let cachedIndex: Map | undefined; + +export function resetPluginPathIndexCache(): void { + cachedPluginPath = undefined; + cachedIndex = undefined; +} + +export function buildPluginPathIndex( + pluginPath: string, + allowlistPrefixes: readonly string[] = DEFAULT_SPECIFIER_ALLOWLIST_PREFIXES, +): Map { + if (cachedPluginPath === pluginPath && cachedIndex) { + return cachedIndex; + } + + const index = new Map(); + + if (!existsSync(pluginPath)) { + cachedPluginPath = pluginPath; + cachedIndex = index; + + return index; + } + + indexPackagesInDirectory(pluginPath, pluginPath, index, allowlistPrefixes); + + const nodeModulesRoot = join(pluginPath, 'node_modules'); + + if (existsSync(nodeModulesRoot)) { + indexScopedPackagesInNodeModules(nodeModulesRoot, pluginPath, index, allowlistPrefixes); + } + + cachedPluginPath = pluginPath; + cachedIndex = index; + + return index; +} + +function indexPackagesInDirectory( + directoryPath: string, + pluginRoot: string, + index: Map, + allowlistPrefixes: readonly string[], +): void { + for (const entry of readdirSync(directoryPath, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name === 'node_modules') { + continue; + } + + const packageDirectory = join(directoryPath, entry.name); + const packageJsonPath = join(packageDirectory, 'package.json'); + + if (!existsSync(packageJsonPath)) { + continue; + } + + addPackageToIndex(packageDirectory, packageJsonPath, pluginRoot, index, allowlistPrefixes); + } +} + +function indexScopedPackagesInNodeModules( + nodeModulesRoot: string, + pluginRoot: string, + index: Map, + allowlistPrefixes: readonly string[], +): void { + for (const entry of readdirSync(nodeModulesRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + + if (entry.name.startsWith('@')) { + const scopeDirectory = join(nodeModulesRoot, entry.name); + + for (const scopedEntry of readdirSync(scopeDirectory, { withFileTypes: true })) { + if (!scopedEntry.isDirectory()) { + continue; + } + + const packageDirectory = join(scopeDirectory, scopedEntry.name); + const packageJsonPath = join(packageDirectory, 'package.json'); + + if (existsSync(packageJsonPath)) { + addPackageToIndex(packageDirectory, packageJsonPath, pluginRoot, index, allowlistPrefixes); + } + } + + continue; + } + + const packageDirectory = join(nodeModulesRoot, entry.name); + const packageJsonPath = join(packageDirectory, 'package.json'); + + if (existsSync(packageJsonPath)) { + addPackageToIndex(packageDirectory, packageJsonPath, pluginRoot, index, allowlistPrefixes); + } + } +} + +function addPackageToIndex( + packageDirectory: string, + packageJsonPath: string, + pluginRoot: string, + index: Map, + allowlistPrefixes: readonly string[], +): void { + const manifest = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as PackageManifest; + + if (!manifest.name) { + return; + } + + try { + assertAllowlistedSpecifier(manifest.name, allowlistPrefixes); + } catch { + return; + } + + const mainPath = resolvePackageMain(packageDirectory, manifest.main); + const target: ProviderLoadTarget = { + source: 'plugin-path', + specifier: manifest.name, + entryPath: packageDirectory, + packageJsonPath, + mainPath, + }; + + index.set(manifest.name, target); +} + +function resolvePackageMain(packageDirectory: string, mainField: string | undefined): string { + if (mainField) { + return join(packageDirectory, mainField); + } + + const indexJs = join(packageDirectory, 'index.js'); + + if (existsSync(indexJs)) { + return indexJs; + } + + return join(packageDirectory, 'index.js'); +} + +export function lookupPluginPathTarget( + packageName: string, + pluginPath: string, + allowlistPrefixes: readonly string[] = DEFAULT_SPECIFIER_ALLOWLIST_PREFIXES, +): ProviderLoadTarget | undefined { + const index = buildPluginPathIndex(pluginPath, allowlistPrefixes); + + return index.get(packageName); +} + +export function buildPluginPathTargetFromRelativePath( + pluginRelativePath: string, + pluginRoot: string, + allowlistPrefixes: readonly string[] = DEFAULT_SPECIFIER_ALLOWLIST_PREFIXES, +): ProviderLoadTarget { + const entryPath = assertPathUnderPluginRoot(pluginRelativePath, pluginRoot); + const packageJsonPath = join(entryPath, 'package.json'); + + if (!existsSync(packageJsonPath)) { + throw new Error(`Plugin directory '${entryPath}' is missing package.json`); + } + + const manifest = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as PackageManifest; + + if (!manifest.name) { + throw new Error(`Plugin package.json at '${packageJsonPath}' is missing 'name'`); + } + + assertAllowlistedSpecifier(manifest.name, allowlistPrefixes); + + return { + source: 'plugin-path', + specifier: manifest.name, + entryPath, + packageJsonPath, + mainPath: resolvePackageMain(entryPath, manifest.main), + }; +} diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/register-dynamic-providers.spec.ts b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/register-dynamic-providers.spec.ts new file mode 100644 index 000000000..63aee7f9d --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/register-dynamic-providers.spec.ts @@ -0,0 +1,37 @@ +import { registerDynamicProviderMetadata, registerDynamicProviders } from './register-dynamic-providers'; + +describe('registerDynamicProviders', () => { + it('registers all loaded instances', async () => { + const register = jest.fn(); + const instances = [{ getType: () => 'a' }, { getType: () => 'b' }]; + + await registerDynamicProviders({ + envKey: 'DYNAMIC_AGENT_PROVIDERS', + criticality: 'optional', + register, + dynamicLoader: { + loadInstances: jest.fn().mockResolvedValue(instances), + }, + }); + + expect(register).toHaveBeenCalledTimes(2); + }); +}); + +describe('registerDynamicProviderMetadata', () => { + it('registers metadata records from loader', async () => { + const register = jest.fn(); + const metadata = [{ id: 'custom', displayName: 'Custom' }]; + + await registerDynamicProviderMetadata({ + envKey: 'DYNAMIC_BILLING_PROVIDER_METADATA', + criticality: 'optional', + register, + dynamicLoader: { + loadMetadata: jest.fn().mockResolvedValue(metadata), + }, + }); + + expect(register).toHaveBeenCalledWith(metadata[0]); + }); +}); diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/register-dynamic-providers.ts b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/register-dynamic-providers.ts new file mode 100644 index 000000000..fac915aec --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/register-dynamic-providers.ts @@ -0,0 +1,41 @@ +import { Logger } from '@nestjs/common'; + +import type { DynamicProviderLoaderService } from './dynamic-provider-loader.service'; +import type { DynamicProviderMetadataRegistrationOptions, DynamicProviderRegistrationOptions } from './types'; + +const defaultLogger = new Logger('DynamicProviderRegistry'); + +/** + * Loads and registers dynamic provider instances from a DYNAMIC_* env var. + * Static registrations in the host module should run before calling this helper. + */ +export async function registerDynamicProviders(options: DynamicProviderRegistrationOptions): Promise { + const logger = options.loggerContext ? new Logger(options.loggerContext) : defaultLogger; + const instances = await options.dynamicLoader.loadInstances(options.envKey, options.criticality, { + failFast: options.failFast, + }); + + for (const instance of instances) { + options.register(instance); + logger.log(`Registered dynamic provider from ${options.envKey}`); + } +} + +/** + * Loads providerMetadata exports from dynamic packages and registers billing UI metadata. + */ +export async function registerDynamicProviderMetadata( + options: DynamicProviderMetadataRegistrationOptions, +): Promise { + const logger = options.loggerContext ? new Logger(options.loggerContext) : defaultLogger; + const metadataRecords = await options.dynamicLoader.loadMetadata(options.envKey, options.criticality, { + failFast: options.failFast, + }); + + for (const metadata of metadataRecords) { + options.register(metadata); + logger.log(`Registered dynamic provider metadata '${metadata.id}' from ${options.envKey}`); + } +} + +export type { DynamicProviderLoaderService }; diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/resolve-provider-export.spec.ts b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/resolve-provider-export.spec.ts new file mode 100644 index 000000000..144a61ecb --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/resolve-provider-export.spec.ts @@ -0,0 +1,90 @@ +import { + instantiateResolvedProvider, + ProviderExportContractError, + resolveProviderExport, + resolveProviderMetadata, +} from './resolve-provider-export'; + +describe('resolveProviderExport', () => { + it('prefers createProvider for plugin packages', () => { + const createProvider = jest.fn(); + + const resolved = resolveProviderExport<{ getType(): string }>( + { createProvider }, + { entry: { specifier: '@forepath/foo' } }, + ); + + expect(resolved.kind).toBe('createProvider'); + expect(resolved.createProvider).toBe(createProvider); + }); + + it('uses named class export from entry alias', () => { + class CustomProvider { + getType(): string { + return 'custom'; + } + } + + const resolved = resolveProviderExport<{ getType(): string }>( + { CustomProvider }, + { entry: { specifier: '@forepath/foo', classExport: 'CustomProvider' } }, + ); + + expect(resolved.kind).toBe('class'); + expect(resolved.providerClass).toBe(CustomProvider); + }); + + it('rejects generic provider exports for plugin packages', () => { + class GenericProvider { + getType(): string { + return 'generic'; + } + } + + expect(() => + resolveProviderExport({ provider: GenericProvider }, { entry: { specifier: '@forepath/foo' } }), + ).toThrow(ProviderExportContractError); + }); + + it('allows generic provider exports for test fixtures', () => { + class FixtureProvider { + getType(): string { + return 'fixture'; + } + } + + const resolved = resolveProviderExport( + { provider: FixtureProvider }, + { entry: { specifier: '@forepath/test/fixture' }, allowTestFixtureExports: true }, + ); + + expect(resolved.kind).toBe('testFixture'); + }); +}); + +describe('resolveProviderMetadata', () => { + it('returns metadata when export is valid', () => { + expect( + resolveProviderMetadata({ + providerMetadata: { id: 'hetzner', displayName: 'Hetzner Cloud' }, + }), + ).toEqual({ id: 'hetzner', displayName: 'Hetzner Cloud' }); + }); + + it('returns undefined when metadata export is missing', () => { + expect(resolveProviderMetadata({})).toBeUndefined(); + }); +}); + +describe('instantiateResolvedProvider', () => { + it('invokes createProvider factory', async () => { + const instance = { getType: () => 'factory' }; + const createProvider = jest.fn().mockResolvedValue(instance); + const moduleRef = { create: jest.fn() } as never; + + const result = await instantiateResolvedProvider({ kind: 'createProvider', createProvider }, moduleRef); + + expect(result).toBe(instance); + expect(createProvider).toHaveBeenCalledWith(moduleRef); + }); +}); diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/resolve-provider-export.ts b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/resolve-provider-export.ts new file mode 100644 index 000000000..b7f493e3b --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/resolve-provider-export.ts @@ -0,0 +1,100 @@ +import type { ModuleRef } from '@nestjs/core'; + +import { readProviderExportHint } from './load-provider-module'; +import type { ProviderCreateFactory, ProviderMetadataRecord, ResolveProviderExportOptions } from './types'; + +export class ProviderExportContractError extends Error { + constructor(message: string) { + super(message); + this.name = 'ProviderExportContractError'; + } +} + +export interface ResolvedProviderExport { + kind: 'createProvider' | 'class' | 'testFixture'; + createProvider?: ProviderCreateFactory; + providerClass?: new (...args: unknown[]) => T; +} + +/** + * Resolves plugin package exports. Generic `provider` / `Provider` are rejected unless test fixtures. + */ +export function resolveProviderExport( + module: Record, + options: ResolveProviderExportOptions, +): ResolvedProviderExport { + const createProvider = module['createProvider']; + + if (typeof createProvider === 'function') { + return { + kind: 'createProvider', + createProvider: createProvider as ProviderCreateFactory, + }; + } + + const classExportName = + options.entry.classExport ?? + readProviderExportHint(options.loadTarget?.specifier ?? options.entry.specifier, process.cwd(), options.loadTarget); + + if (classExportName) { + const providerClass = module[classExportName]; + + if (typeof providerClass === 'function') { + return { + kind: 'class', + providerClass: providerClass as new (...args: unknown[]) => T, + }; + } + + throw new ProviderExportContractError( + `Provider package '${options.entry.specifier}' declares class export '${classExportName}' but it is missing or not a constructor`, + ); + } + + if (options.allowTestFixtureExports) { + const fixtureExport = module['provider'] ?? module['Provider']; + + if (typeof fixtureExport === 'function') { + return { + kind: 'testFixture', + providerClass: fixtureExport as new (...args: unknown[]) => T, + }; + } + } + + throw new ProviderExportContractError( + `Provider package '${options.entry.specifier}' must export 'createProvider' or a named PascalCase class ` + + `(via entry alias or package.json forepath.providerExport). Generic 'provider'/'Provider' exports are not accepted.`, + ); +} + +export async function instantiateResolvedProvider( + resolved: ResolvedProviderExport, + moduleRef: ModuleRef, +): Promise { + if (resolved.kind === 'createProvider' && resolved.createProvider) { + return await resolved.createProvider(moduleRef); + } + + if (resolved.providerClass) { + return moduleRef.create(resolved.providerClass); + } + + throw new ProviderExportContractError('Resolved provider export has no instantiable export'); +} + +export function resolveProviderMetadata(module: Record): ProviderMetadataRecord | undefined { + const metadata = module['providerMetadata']; + + if (!metadata || typeof metadata !== 'object') { + return undefined; + } + + const record = metadata as ProviderMetadataRecord; + + if (!record.id || !record.displayName) { + throw new ProviderExportContractError('providerMetadata export must include id and displayName'); + } + + return record; +} diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/resolve-provider-load-target.spec.ts b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/resolve-provider-load-target.spec.ts new file mode 100644 index 000000000..332fa1623 --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/resolve-provider-load-target.spec.ts @@ -0,0 +1,44 @@ +import { join } from 'node:path'; + +import { resetPluginPathIndexCache } from './plugin-path-index'; +import { ProviderLoadTargetError, resolveProviderLoadTarget } from './resolve-provider-load-target'; + +const appRoot = join(__dirname, '../test-fixtures/app-root-fixture'); +const pluginRoot = join(__dirname, '../test-fixtures/plugin-root'); + +describe('resolveProviderLoadTarget', () => { + beforeEach(() => { + resetPluginPathIndexCache(); + }); + + it('resolves file: entries from plugin path', () => { + const target = resolveProviderLoadTarget( + { + specifier: 'file:mounted-plugin-fixture', + pluginRelativePath: 'mounted-plugin-fixture', + }, + { appRoot, pluginPath: pluginRoot }, + ); + + expect(target.source).toBe('plugin-path'); + expect(target.specifier).toBe('@forepath/test/mounted-plugin-fixture'); + }); + + it('falls back to plugin path when package is not baked in', () => { + const target = resolveProviderLoadTarget( + { specifier: '@forepath/test/mounted-plugin-fixture' }, + { appRoot, pluginPath: pluginRoot }, + ); + + expect(target.source).toBe('plugin-path'); + }); + + it('fails when package is missing from baked-in graph and plugin path', () => { + expect(() => + resolveProviderLoadTarget( + { specifier: '@forepath/missing/package' }, + { appRoot, pluginPath: pluginRoot, envKey: 'DYNAMIC_TEST' }, + ), + ).toThrow(ProviderLoadTargetError); + }); +}); diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/resolve-provider-load-target.ts b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/resolve-provider-load-target.ts new file mode 100644 index 000000000..bb4a19aef --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/resolve-provider-load-target.ts @@ -0,0 +1,76 @@ +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; + +import { assertRuntimeDependency, RuntimeDependencyError } from './assert-runtime-dependency'; +import { isFilePluginEntry } from './parse-provider-package-spec'; +import { buildPluginPathTargetFromRelativePath, lookupPluginPathTarget } from './plugin-path-index'; +import type { ProviderLoadTarget, ProviderPackageEntry, ResolveProviderLoadTargetOptions } from './types'; + +export class ProviderLoadTargetError extends Error { + constructor( + message: string, + readonly entry: ProviderPackageEntry, + readonly envKey?: string, + ) { + super(message); + this.name = 'ProviderLoadTargetError'; + } +} + +export function resolveProviderLoadTarget( + entry: ProviderPackageEntry, + options: ResolveProviderLoadTargetOptions = {}, +): ProviderLoadTarget { + const appRoot = options.appRoot ?? process.cwd(); + const pluginPath = options.pluginPath; + const envKey = options.envKey; + + if (isFilePluginEntry(entry)) { + if (!pluginPath) { + throw new ProviderLoadTargetError( + `Plugin entry '${entry.specifier}' requires ${'DYNAMIC_PROVIDER_PLUGIN_PATH'} to be set`, + entry, + envKey, + ); + } + + return buildPluginPathTargetFromRelativePath(entry.pluginRelativePath!, pluginPath, options.allowlistPrefixes); + } + + try { + assertRuntimeDependency(entry.specifier, { ...options, appRoot, envKey }); + + const require = createRequire(join(appRoot, 'package.json')); + const resolvedEntry = require.resolve(entry.specifier); + const packageJsonPath = require.resolve(`${entry.specifier}/package.json`); + const manifest = require(packageJsonPath) as { main?: string }; + + return { + source: 'baked-in', + specifier: entry.specifier, + entryPath: dirname(packageJsonPath), + packageJsonPath, + mainPath: manifest.main ? join(dirname(packageJsonPath), manifest.main) : resolvedEntry, + }; + } catch (error) { + if (!(error instanceof RuntimeDependencyError) || !pluginPath) { + if (error instanceof RuntimeDependencyError) { + throw new ProviderLoadTargetError(error.message, entry, envKey); + } + + throw error; + } + + const pluginTarget = lookupPluginPathTarget(entry.specifier, pluginPath, options.allowlistPrefixes); + + if (!pluginTarget) { + throw new ProviderLoadTargetError( + `Package '${entry.specifier}' is not baked into app root '${appRoot}' and was not found under plugin path '${pluginPath}'`, + entry, + envKey, + ); + } + + return pluginTarget; + } +} diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/startup-error-policy.spec.ts b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/startup-error-policy.spec.ts new file mode 100644 index 000000000..5c6b465c3 --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/startup-error-policy.spec.ts @@ -0,0 +1,56 @@ +import { DYNAMIC_PROVIDERS_FAIL_FAST_ENV } from './types'; +import { handleDynamicProviderError, shouldFailFastOnError } from './startup-error-policy'; + +describe('startup-error-policy', () => { + const originalEnv = process.env[DYNAMIC_PROVIDERS_FAIL_FAST_ENV]; + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env[DYNAMIC_PROVIDERS_FAIL_FAST_ENV]; + } else { + process.env[DYNAMIC_PROVIDERS_FAIL_FAST_ENV] = originalEnv; + } + }); + + it('never fail-fast for optional registries', () => { + process.env[DYNAMIC_PROVIDERS_FAIL_FAST_ENV] = 'true'; + expect(shouldFailFastOnError('optional')).toBe(false); + }); + + it('fail-fast for critical registries when env flag is true', () => { + process.env[DYNAMIC_PROVIDERS_FAIL_FAST_ENV] = 'true'; + expect(shouldFailFastOnError('critical')).toBe(true); + }); + + it('remains permissive for critical registries when env flag is unset', () => { + delete process.env[DYNAMIC_PROVIDERS_FAIL_FAST_ENV]; + expect(shouldFailFastOnError('critical')).toBe(false); + }); + + it('skips errors permissively for optional registries', () => { + const onPermissive = jest.fn(); + + handleDynamicProviderError(new Error('load failed'), { + criticality: 'optional', + envKey: 'DYNAMIC_CHAT_FILTERS', + entryLabel: '@forepath/foo', + onPermissive, + }); + + expect(onPermissive).toHaveBeenCalled(); + }); + + it('rethrows for critical registries when fail-fast is enabled', () => { + process.env[DYNAMIC_PROVIDERS_FAIL_FAST_ENV] = 'true'; + const error = new Error('load failed'); + + expect(() => + handleDynamicProviderError(error, { + criticality: 'critical', + envKey: 'DYNAMIC_PROVISIONING_PROVIDERS', + entryLabel: '@forepath/foo', + onPermissive: jest.fn(), + }), + ).toThrow(error); + }); +}); diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/startup-error-policy.ts b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/startup-error-policy.ts new file mode 100644 index 000000000..7b665f4f9 --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/startup-error-policy.ts @@ -0,0 +1,49 @@ +import { DYNAMIC_PROVIDERS_FAIL_FAST_ENV, type RegistryCriticality } from './types'; + +export function isDynamicProvidersFailFastEnabled(env: NodeJS.ProcessEnv = process.env, override?: boolean): boolean { + if (override !== undefined) { + return override; + } + + return env[DYNAMIC_PROVIDERS_FAIL_FAST_ENV] === 'true'; +} + +/** + * Returns true when startup should abort instead of skipping a failed dynamic provider entry. + */ +export function shouldFailFastOnError( + criticality: RegistryCriticality, + failFast?: boolean, + env: NodeJS.ProcessEnv = process.env, +): boolean { + if (criticality === 'optional') { + return false; + } + + return isDynamicProvidersFailFastEnabled(env, failFast); +} + +export function handleDynamicProviderError( + error: unknown, + context: { + criticality: RegistryCriticality; + failFast?: boolean; + envKey: string; + entryLabel: string; + onPermissive: (message: string, error: unknown) => void; + }, +): void { + const message = + `Failed to load dynamic provider entry '${context.entryLabel}' from ${context.envKey}: ` + + `${error instanceof Error ? error.message : String(error)}`; + + if (shouldFailFastOnError(context.criticality, context.failFast)) { + if (error instanceof Error) { + throw error; + } + + throw new Error(message); + } + + context.onPermissive(message, error); +} diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/types.ts b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/types.ts new file mode 100644 index 000000000..ae12ae461 --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/lib/types.ts @@ -0,0 +1,113 @@ +import type { ModuleRef } from '@nestjs/core'; + +export const DEFAULT_SPECIFIER_ALLOWLIST_PREFIXES = ['@forepath/', '@agenstra/'] as const; + +export const DYNAMIC_PROVIDERS_FAIL_FAST_ENV = 'DYNAMIC_PROVIDERS_FAIL_FAST'; + +export const DYNAMIC_PROVIDER_PLUGIN_PATH_ENV = 'DYNAMIC_PROVIDER_PLUGIN_PATH'; + +export const DYNAMIC_PROVIDER_PLUGIN_INSTALL_ENV = 'DYNAMIC_PROVIDER_PLUGIN_INSTALL'; + +export type RegistryCriticality = 'critical' | 'optional'; + +export type ProviderLoadSource = 'baked-in' | 'plugin-path'; + +export type ProviderCreateFactory = (moduleRef: ModuleRef) => T | Promise; + +export interface ProviderPackageEntry { + /** + * Optional non-PascalCase label used in logs (e.g. `custom` in `custom=@forepath/foo`). + */ + alias?: string; + /** + * Package specifier (e.g. `@forepath/agenstra/backend/provisioning-custom`) or `file:relative-dir`. + */ + specifier: string; + /** + * PascalCase class export name when not using `createProvider`. + */ + classExport?: string; + /** + * Relative path under the plugin root when specifier uses `file:`. + */ + pluginRelativePath?: string; +} + +export interface ProviderLoadTarget { + source: ProviderLoadSource; + /** Logical package id for logs and export hints. */ + specifier: string; + /** Absolute path to package root directory. */ + entryPath: string; + /** Absolute path to package.json. */ + packageJsonPath: string; + /** Resolved main entry file path. */ + mainPath: string; +} + +export interface ProviderMetadataRecord { + id: string; + displayName: string; + configSchema?: Record; +} + +export interface LoadedProviderModule { + entry: ProviderPackageEntry; + module: Record; +} + +export interface AssertRuntimeDependencyOptions { + appRoot?: string; + envKey?: string; + allowlistPrefixes?: readonly string[]; +} + +export interface ResolveProviderLoadTargetOptions { + appRoot?: string; + pluginPath?: string; + envKey?: string; + allowlistPrefixes?: readonly string[]; +} + +export interface LoadProviderModuleOptions extends ResolveProviderLoadTargetOptions { + envKey?: string; +} + +export interface ResolveProviderExportOptions { + entry: ProviderPackageEntry; + loadTarget?: ProviderLoadTarget; + /** + * When true, allows generic `provider` / `Provider` exports (test fixtures only). + */ + allowTestFixtureExports?: boolean; +} + +export interface DynamicProviderRegistrationOptions { + envKey: string; + criticality: RegistryCriticality; + register: (instance: T) => void; + dynamicLoader: { + loadInstances: ( + envKey: string, + criticality: RegistryCriticality, + options?: { failFast?: boolean; appRoot?: string; pluginPath?: string }, + ) => Promise; + }; + failFast?: boolean; + loggerContext?: string; +} + +export interface DynamicProviderMetadataRegistrationOptions { + envKey: string; + criticality: RegistryCriticality; + register: (metadata: ProviderMetadataRecord) => void; + dynamicLoader: { + loadMetadata: ( + envKey: string, + criticality: RegistryCriticality, + options?: { failFast?: boolean; appRoot?: string; pluginPath?: string }, + ) => Promise; + }; + failFast?: boolean; + loggerContext?: string; +} diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/app-root-fixture/package.json b/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/app-root-fixture/package.json new file mode 100644 index 000000000..59e5a5333 --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/app-root-fixture/package.json @@ -0,0 +1,8 @@ +{ + "name": "@forepath/test/app-root-fixture", + "version": "0.0.1", + "private": true, + "dependencies": { + "@forepath/test/runtime-provider-fixture": "file:../runtime-provider-fixture" + } +} diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/global-setup.cjs b/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/global-setup.cjs new file mode 100644 index 000000000..78ebef68e --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/global-setup.cjs @@ -0,0 +1,3 @@ +module.exports = async () => { + require('./setup-runtime-fixture.cjs'); +}; diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/mounted-plugin-fixture/index.js b/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/mounted-plugin-fixture/index.js new file mode 100644 index 000000000..f794de604 --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/mounted-plugin-fixture/index.js @@ -0,0 +1,20 @@ +class MountedFixtureProvider { + getType() { + return 'mounted-fixture-provider'; + } +} + +function createProvider() { + return new MountedFixtureProvider(); +} + +const providerMetadata = { + id: 'mounted-fixture-provider', + displayName: 'Mounted Fixture Provider', +}; + +module.exports = { + createProvider, + MountedFixtureProvider, + providerMetadata, +}; diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/mounted-plugin-fixture/package.json b/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/mounted-plugin-fixture/package.json new file mode 100644 index 000000000..4f6b1ae10 --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/mounted-plugin-fixture/package.json @@ -0,0 +1,6 @@ +{ + "name": "@forepath/test/mounted-plugin-fixture", + "version": "0.0.1", + "private": true, + "main": "index.js" +} diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/plugin-root/mounted-plugin-fixture/index.js b/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/plugin-root/mounted-plugin-fixture/index.js new file mode 100644 index 000000000..f794de604 --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/plugin-root/mounted-plugin-fixture/index.js @@ -0,0 +1,20 @@ +class MountedFixtureProvider { + getType() { + return 'mounted-fixture-provider'; + } +} + +function createProvider() { + return new MountedFixtureProvider(); +} + +const providerMetadata = { + id: 'mounted-fixture-provider', + displayName: 'Mounted Fixture Provider', +}; + +module.exports = { + createProvider, + MountedFixtureProvider, + providerMetadata, +}; diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/plugin-root/mounted-plugin-fixture/package.json b/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/plugin-root/mounted-plugin-fixture/package.json new file mode 100644 index 000000000..4f6b1ae10 --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/plugin-root/mounted-plugin-fixture/package.json @@ -0,0 +1,6 @@ +{ + "name": "@forepath/test/mounted-plugin-fixture", + "version": "0.0.1", + "private": true, + "main": "index.js" +} diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/runtime-provider-fixture/index.js b/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/runtime-provider-fixture/index.js new file mode 100644 index 000000000..33382ea78 --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/runtime-provider-fixture/index.js @@ -0,0 +1,20 @@ +class FixtureProvider { + getType() { + return 'fixture-provider'; + } +} + +function createProvider() { + return new FixtureProvider(); +} + +const providerMetadata = { + id: 'fixture-provider', + displayName: 'Fixture Provider', +}; + +module.exports = { + createProvider, + FixtureProvider, + providerMetadata, +}; diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/runtime-provider-fixture/package.json b/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/runtime-provider-fixture/package.json new file mode 100644 index 000000000..1562c0880 --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/runtime-provider-fixture/package.json @@ -0,0 +1,6 @@ +{ + "name": "@forepath/test/runtime-provider-fixture", + "version": "0.0.1", + "private": true, + "main": "index.js" +} diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/setup-runtime-fixture.cjs b/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/setup-runtime-fixture.cjs new file mode 100644 index 000000000..26f16a35b --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/src/test-fixtures/setup-runtime-fixture.cjs @@ -0,0 +1,32 @@ +const { + cpSync, + existsSync, + mkdirSync, + rmSync, + symlinkSync, +} = require('node:fs'); +const { dirname, join } = require('node:path'); + +const fixturesRoot = join(__dirname, '.'); +const appRoot = join(fixturesRoot, 'app-root-fixture'); +const pluginRoot = join(fixturesRoot, 'plugin-root'); +const providerFixture = join(fixturesRoot, 'runtime-provider-fixture'); +const mountedFixture = join(fixturesRoot, 'mounted-plugin-fixture'); +const linkedModulePath = join( + appRoot, + 'node_modules/@forepath/test/runtime-provider-fixture', +); +const mountedPluginCopy = join(pluginRoot, 'mounted-plugin-fixture'); + +mkdirSync(dirname(linkedModulePath), { recursive: true }); +mkdirSync(pluginRoot, { recursive: true }); + +if (!existsSync(linkedModulePath)) { + symlinkSync(providerFixture, linkedModulePath, 'junction'); +} + +if (existsSync(mountedPluginCopy)) { + rmSync(mountedPluginCopy, { recursive: true, force: true }); +} + +cpSync(mountedFixture, mountedPluginCopy, { recursive: true, force: true }); diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/tsconfig.json b/libs/domains/shared/backend/util-dynamic-provider-registry/tsconfig.json new file mode 100644 index 000000000..9e5fde1c6 --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs" + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/tsconfig.lib.json b/libs/domains/shared/backend/util-dynamic-provider-registry/tsconfig.lib.json new file mode 100644 index 000000000..841e950a0 --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/tsconfig.lib.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": [ + "jest.config.ts", + "jest.config.cts", + "src/**/*.spec.ts", + "src/**/*.test.ts" + ] +} diff --git a/libs/domains/shared/backend/util-dynamic-provider-registry/tsconfig.spec.json b/libs/domains/shared/backend/util-dynamic-provider-registry/tsconfig.spec.json new file mode 100644 index 000000000..c714b3c64 --- /dev/null +++ b/libs/domains/shared/backend/util-dynamic-provider-registry/tsconfig.spec.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "jest.config.cts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/tools/ci/verify-install-provider-plugins.sh b/tools/ci/verify-install-provider-plugins.sh new file mode 100755 index 000000000..f5604bf70 --- /dev/null +++ b/tools/ci/verify-install-provider-plugins.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +install_script="$1" + +if [ ! -f "$install_script" ]; then + echo "Install script not found: $install_script" >&2 + exit 1 +fi + +plugin_path="$(mktemp -d /tmp/forepath-provider-plugin-smoke.XXXXXX)" +trap 'rm -rf "$plugin_path"' EXIT + +export DYNAMIC_PROVIDER_PLUGIN_PATH="$plugin_path" +node "$install_script" diff --git a/tsconfig.base.json b/tsconfig.base.json index bd386347b..eadc7b10b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -107,6 +107,9 @@ ], "@forepath/decabill/frontend/feature-billing-console": [ "libs/domains/decabill/frontend/feature-billing-console/src/index.ts" + ], + "@forepath/shared/backend/util-dynamic-provider-registry": [ + "libs/domains/shared/backend/util-dynamic-provider-registry/src/index.ts" ] } },