diff --git a/.gitignore b/.gitignore index 66e09c86..50b9ee63 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,8 @@ gradle-app.setting .direnv/ logs + +# Prevent accidental npm installs at the repo root +/node_modules/ +/package.json +/package-lock.json \ No newline at end of file diff --git a/README.md b/README.md index 0e3c3753..b0be2fa8 100644 --- a/README.md +++ b/README.md @@ -64,9 +64,33 @@ $ make shell ``` An assistant helps set up deployment when running `make start` for the first time. -You can choose to run the application in standard mode or test mode and with or without OAUTH2. +You can choose to run the application in standard mode or test mode, with or without OAUTH2, and which backend implementation to run (`java` or `js`). You may change this later by running `make setup`. +## Backend implementations + +Quickstart ships two interchangeable backend implementations behind the same HTTP API ([quickstart/common/openapi.yaml](quickstart/common/openapi.yaml)). Both listen on `BACKEND_PORT` (default `8080`), share the same env files under [quickstart/docker/backend-service/](quickstart/docker/backend-service/), the same `onboarding` volume, and the same cookie + CSRF auth surface (oauth2 and shared-secret). + +| Backend | Stack | Ledger transport | Source | +|---------|-------|------------------|--------| +| `java` (default) | JDK 21, Spring Boot 3.4 | gRPC (`canton:3901`) | [quickstart/backend/](quickstart/backend/) | +| `js` | Node 22, TypeScript, Fastify | JSON Ledger API (`canton:3975`) | [quickstart/backend-js/](quickstart/backend-js/) | + +Selection is persisted in `quickstart/.env.local` by `make setup` (which prompts for `BACKEND`). You can also override it per invocation: + +```bash +make build BACKEND=js # build only the JS backend image +make start BACKEND=js # bring up the stack with the JS backend in place of the Java service +``` + +Switching backends requires a clean restart so Postgres state and onboarding volumes are recreated: + +```bash +make clean-all && make start BACKEND=js +``` + +`make build-daml` runs only the codegen the active backend consumes — `:daml:tsCodegen` (TypeScript bindings under [quickstart/backend-js/generated/](quickstart/backend-js/generated/)) for `BACKEND=js`, and `:daml:codeGen` + `distTar` (Java bindings) otherwise. The JS Compose override at [quickstart/docker/backend-js/compose.yaml](quickstart/docker/backend-js/compose.yaml) uses Docker Compose `!reset` / `!override` directives, so **Docker Compose v2.24 or newer is required** when running with `BACKEND=js`. + ## Debugging TL;DR If a container fails to start, there are a few things to try: @@ -74,7 +98,7 @@ If a container fails to start, there are a few things to try: - Ensure Docker Compose is configured to allocate enough memory. The recommended minimum total memory is 8 GB. - Start fresh with `make clean-all` and then manually delete all Docker images and volumes. -**Note**: The CN Quickstart uses Java SDK version `Eclipse Temurin JDK version 21` which runs within the Docker container. This information is specified in `quickstart/compose.yaml` and `.env`. +**Note**: When running the default Java backend (`BACKEND=java`), the CN Quickstart uses Java SDK version `Eclipse Temurin JDK version 21` inside the Docker container. This information is specified in `quickstart/compose.yaml` and `.env`. The Node.js backend (`BACKEND=js`) uses the Node 22 image defined in [quickstart/docker/backend-js/Dockerfile](quickstart/docker/backend-js/Dockerfile). If you need assistance, please follow these directions to gather the log information needed for debugging: 1. `make setup` # optional @@ -215,6 +239,8 @@ working_dir: /app This configuration demonstrates how the `backend-service` relies on the Quickstart-provided infrastructure. Quickstart automates much of the local environment setup for LocalNet, allowing you to prioritize application development. As you progress toward deployment and explore cloud orchestration, a deeper grasp of service configuration is invaluable. For now, consider these services a ready-to-use infrastructure foundation. +> **Note**: the YAML above is the resolved configuration for the default Java backend. When running with `BACKEND=js`, the same `make compose-config` command produces a Node.js variant: the `image` is the `backend-js` Node 22 image, `LEDGER_PORT` is `3975` (the JSON Ledger API port instead of the gRPC port `3901`), and the bind mount sources from [quickstart/backend-js/](quickstart/backend-js/) instead of `backend/build/distributions/backend.tar`. All other fields (env files, onboarding volume, `BACKEND_PORT`, dependencies on `pqs-app-provider` and `splice-onboarding`) are identical. + Then explore `register-app-user-tenant`, the service that registers AppUser tenants to the `backend-service`. This allows end users from the AppUser organization to log in and quickly start the web UI. That, in turn, ties the AppUser Identity Provider to the AppUser primary party ID. If the end user is logged in through this Identity Provider, the user can then act as the AppUser primary party. The `register-app-user-tenant` service utilizes functionality provided by the `splice-onboarding` module to make the task as simple as possible. This step can also be performed manually through the web UI if you log in to Quickstart as `app-provider` and navigate to the tenants tab. At that tab, you can also see a list of registered tenants and verify that the `AppUser` tenant was automatically pre-registered for you by `register-app-user-tenant`. @@ -261,8 +287,8 @@ The `AppUser` organization is registered on the Quickstart startup by calling th | Service | Port | Description | |---------|------|-------------| -| Backend Service | 8080 | Spring Boot backend for Licensing workflow | -| Backend Debug (JVM) | 5005 | Remote JVM debugging (when `DEBUG_ENABLED=true`) | +| Backend Service | 8080 | Spring Boot (Java) **or** Fastify (Node.js, `BACKEND=js`) backend for the Licensing workflow | +| Backend Debug (JVM) | 5005 | Remote JVM debugging — Java backend only, when `DEBUG_ENABLED=true` | ## Canton Participant Ledger API @@ -503,10 +529,10 @@ Run: ```bash make restart-backend ``` -This target restarts the backend, handles dependent services (e.g., register-app-user-tenant), and rebuilds the service if needed. +This target restarts the backend, handles dependent services (e.g., register-app-user-tenant), and rebuilds the service if needed. It works for both backends — it rebuilds and recreates whichever implementation the active `BACKEND` value (set in `.env.local` by `make setup`, or overridden per command) selects. #### Debug backend service -Enable remote JVM debugging by setting: +Remote debugging via `DEBUG_ENABLED=true` is **Java-backend only**. Enable remote JVM debugging by setting: ```bash export DEBUG_ENABLED=true make restart-backend @@ -520,6 +546,8 @@ Configure your IDE (IntelliJ, VS Code) to attach to port 5005 for step-through d Example in IntelliJ Idea ![remote-debug-settings](sdk/docs/images/remote-debug-settings.png) +The Node.js backend (`BACKEND=js`) does not expose a Node inspector by default; `DEBUG_ENABLED=true` is a no-op in JS mode. To attach a Node debugger, run the JS backend locally outside Docker (`cd quickstart/backend-js && npm install && node --inspect=0.0.0.0:9229 ...`) or extend [quickstart/docker/backend-js/start.sh](quickstart/docker/backend-js/start.sh) to pass `--inspect` and publish port 9229 from [quickstart/docker/backend-js/compose.yaml](quickstart/docker/backend-js/compose.yaml). + ### Viewing logs For interactive local log inspection we recommend lnav (https://lnav.org/). Install the Canton log format and use it to view ``*.clog`` files. Example Canton lnav format definition: https://github.com/hyperledger-labs/splice/blob/main/canton/canton-json.lnav.json diff --git a/quickstart/Makefile b/quickstart/Makefile index 3cb2b7cf..ef46c738 100644 --- a/quickstart/Makefile +++ b/quickstart/Makefile @@ -104,6 +104,13 @@ endif ############################################################################ #### backend-service ############################################################################ +# Backend implementation selector. Set via `make setup`; java (default) or js. +# Override per-invocation with `make BACKEND=js` if needed. +BACKEND ?= java +ifeq ($(BACKEND),js) + DOCKER_COMPOSE_FILES += -f ./docker/backend-js/compose.yaml +endif + ifeq ($(RESOURCE_CONSTRAINTS_ENABLED),true) RESOURCE_CONSTRAINT_CONFIG += -f ./docker/backend-service/resource-constraints.yaml endif @@ -158,12 +165,27 @@ build-frontend: ## Build the frontend application cd frontend && npm install && npm run build .PHONY: build-backend -build-backend: ## Build the backend service - ./gradlew :backend:build +build-backend: ## Build the backend service (Java by default; Node when BACKEND=js) +ifeq ($(BACKEND),js) + $(MAKE) build-backend-js +else + ./gradlew :backend:build -x :daml:tsCodegen +endif +.PHONY: build-backend-js +build-backend-js: docker-available build-daml ## Build the JS backend Docker image + $(call docker-compose, build backend-service) + +# Skip the codegen the active backend doesn't consume. distTar produces backend.tar for the +# Java image; the JS image doesn't consume it, and including it in the JS path would +# transitively re-pull :daml:codeGen via :backend:compileJava. .PHONY: build-daml build-daml: ## Build the Daml model - ./gradlew :daml:build distTar +ifeq ($(BACKEND),js) + ./gradlew :daml:build -x :daml:codeGen +else + ./gradlew :daml:build distTar -x :daml:tsCodegen +endif .PHONY: docker-available docker-available: ## Check if Docker CLI exists and is running diff --git a/quickstart/backend-js/.gitignore b/quickstart/backend-js/.gitignore new file mode 100644 index 00000000..78fc095a --- /dev/null +++ b/quickstart/backend-js/.gitignore @@ -0,0 +1,5 @@ +node_modules +dist +generated +src/token-standard/metadata.types.ts +src/token-standard/allocation.types.ts diff --git a/quickstart/backend-js/package-lock.json b/quickstart/backend-js/package-lock.json new file mode 100644 index 00000000..06d97d7d --- /dev/null +++ b/quickstart/backend-js/package-lock.json @@ -0,0 +1,2152 @@ +{ + "name": "backend-js", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "backend-js", + "workspaces": [ + "generated/*" + ], + "dependencies": { + "@daml.js/quickstart-licensing-0.0.1": "*", + "@daml/types": "3.4.11", + "@fastify/cookie": "^11.0.2", + "@fastify/formbody": "^8.0.2", + "@fastify/session": "^11.1.1", + "fastify": "^5.8.5", + "jose": "^6.2.3", + "openid-client": "^6.8.4", + "pg": "^8.20.0", + "pino": "^10.3.1" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "@types/pg": "^8.20.0", + "openapi-typescript": "^7.13.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22" + } + }, + "generated/daml-prim-DA-Exception-ArithmeticError-1.0.0": { + "name": "@daml.js/daml-prim-DA-Exception-ArithmeticError-1.0.0", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "generated/daml-prim-DA-Exception-AssertionFailed-1.0.0": { + "name": "@daml.js/daml-prim-DA-Exception-AssertionFailed-1.0.0", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "generated/daml-prim-DA-Exception-GeneralError-1.0.0": { + "name": "@daml.js/daml-prim-DA-Exception-GeneralError-1.0.0", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "generated/daml-prim-DA-Exception-PreconditionFailed-1.0.0": { + "name": "@daml.js/daml-prim-DA-Exception-PreconditionFailed-1.0.0", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "generated/daml-prim-DA-Types-1.0.0": { + "name": "@daml.js/daml-prim-DA-Types-1.0.0", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "generated/daml-prim-GHC-Tuple-1.0.0": { + "name": "@daml.js/daml-prim-GHC-Tuple-1.0.0", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "generated/daml-prim-GHC-Types-1.0.0": { + "name": "@daml.js/daml-prim-GHC-Types-1.0.0", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "generated/daml-stdlib-DA-Date-Types-1.0.0": { + "name": "@daml.js/daml-stdlib-DA-Date-Types-1.0.0", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "generated/daml-stdlib-DA-Internal-Down-1.0.0": { + "name": "@daml.js/daml-stdlib-DA-Internal-Down-1.0.0", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "generated/daml-stdlib-DA-Logic-Types-1.0.0": { + "name": "@daml.js/daml-stdlib-DA-Logic-Types-1.0.0", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "generated/daml-stdlib-DA-Monoid-Types-1.0.0": { + "name": "@daml.js/daml-stdlib-DA-Monoid-Types-1.0.0", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "generated/daml-stdlib-DA-NonEmpty-Types-1.0.0": { + "name": "@daml.js/daml-stdlib-DA-NonEmpty-Types-1.0.0", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "generated/daml-stdlib-DA-Random-Types-1.0.0": { + "name": "@daml.js/daml-stdlib-DA-Random-Types-1.0.0", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "generated/daml-stdlib-DA-Semigroup-Types-1.0.0": { + "name": "@daml.js/daml-stdlib-DA-Semigroup-Types-1.0.0", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "generated/daml-stdlib-DA-Set-Types-1.0.0": { + "name": "@daml.js/daml-stdlib-DA-Set-Types-1.0.0", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "generated/daml-stdlib-DA-Stack-Types-1.0.0": { + "name": "@daml.js/daml-stdlib-DA-Stack-Types-1.0.0", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "generated/daml-stdlib-DA-Time-Types-1.0.0": { + "name": "@daml.js/daml-stdlib-DA-Time-Types-1.0.0", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "generated/daml-stdlib-DA-Validation-Types-1.0.0": { + "name": "@daml.js/daml-stdlib-DA-Validation-Types-1.0.0", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@daml.js/daml-stdlib-DA-NonEmpty-Types-1.0.0": "file:../daml-stdlib-DA-NonEmpty-Types-1.0.0", + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "generated/ghc-stdlib-DA-Internal-Template-1.0.0": { + "name": "@daml.js/ghc-stdlib-DA-Internal-Template-1.0.0", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "generated/quickstart-licensing-0.0.1": { + "name": "@daml.js/quickstart-licensing-0.0.1", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@daml.js/daml-stdlib-DA-Time-Types-1.0.0": "file:../daml-stdlib-DA-Time-Types-1.0.0", + "@daml.js/ghc-stdlib-DA-Internal-Template-1.0.0": "file:../ghc-stdlib-DA-Internal-Template-1.0.0", + "@daml.js/splice-api-token-allocation-request-v1-1.0.0": "file:../splice-api-token-allocation-request-v1-1.0.0", + "@daml.js/splice-api-token-allocation-v1-1.0.0": "file:../splice-api-token-allocation-v1-1.0.0", + "@daml.js/splice-api-token-holding-v1-1.0.0": "file:../splice-api-token-holding-v1-1.0.0", + "@daml.js/splice-api-token-metadata-v1-1.0.0": "file:../splice-api-token-metadata-v1-1.0.0", + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "generated/splice-api-token-allocation-request-v1-1.0.0": { + "name": "@daml.js/splice-api-token-allocation-request-v1-1.0.0", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@daml.js/ghc-stdlib-DA-Internal-Template-1.0.0": "file:../ghc-stdlib-DA-Internal-Template-1.0.0", + "@daml.js/splice-api-token-allocation-v1-1.0.0": "file:../splice-api-token-allocation-v1-1.0.0", + "@daml.js/splice-api-token-metadata-v1-1.0.0": "file:../splice-api-token-metadata-v1-1.0.0", + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "generated/splice-api-token-allocation-v1-1.0.0": { + "name": "@daml.js/splice-api-token-allocation-v1-1.0.0", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@daml.js/ghc-stdlib-DA-Internal-Template-1.0.0": "file:../ghc-stdlib-DA-Internal-Template-1.0.0", + "@daml.js/splice-api-token-holding-v1-1.0.0": "file:../splice-api-token-holding-v1-1.0.0", + "@daml.js/splice-api-token-metadata-v1-1.0.0": "file:../splice-api-token-metadata-v1-1.0.0", + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "generated/splice-api-token-holding-v1-1.0.0": { + "name": "@daml.js/splice-api-token-holding-v1-1.0.0", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@daml.js/daml-stdlib-DA-Time-Types-1.0.0": "file:../daml-stdlib-DA-Time-Types-1.0.0", + "@daml.js/ghc-stdlib-DA-Internal-Template-1.0.0": "file:../ghc-stdlib-DA-Internal-Template-1.0.0", + "@daml.js/splice-api-token-metadata-v1-1.0.0": "file:../splice-api-token-metadata-v1-1.0.0", + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "generated/splice-api-token-metadata-v1-1.0.0": { + "name": "@daml.js/splice-api-token-metadata-v1-1.0.0", + "version": "0.0.0", + "license": "UNLICENSED", + "dependencies": { + "@daml.js/daml-stdlib-DA-Time-Types-1.0.0": "file:../daml-stdlib-DA-Time-Types-1.0.0", + "@daml.js/ghc-stdlib-DA-Internal-Template-1.0.0": "file:../ghc-stdlib-DA-Internal-Template-1.0.0", + "@mojotech/json-type-validation": "^3.1.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@daml.js/daml-prim-DA-Exception-ArithmeticError-1.0.0": { + "resolved": "generated/daml-prim-DA-Exception-ArithmeticError-1.0.0", + "link": true + }, + "node_modules/@daml.js/daml-prim-DA-Exception-AssertionFailed-1.0.0": { + "resolved": "generated/daml-prim-DA-Exception-AssertionFailed-1.0.0", + "link": true + }, + "node_modules/@daml.js/daml-prim-DA-Exception-GeneralError-1.0.0": { + "resolved": "generated/daml-prim-DA-Exception-GeneralError-1.0.0", + "link": true + }, + "node_modules/@daml.js/daml-prim-DA-Exception-PreconditionFailed-1.0.0": { + "resolved": "generated/daml-prim-DA-Exception-PreconditionFailed-1.0.0", + "link": true + }, + "node_modules/@daml.js/daml-prim-DA-Types-1.0.0": { + "resolved": "generated/daml-prim-DA-Types-1.0.0", + "link": true + }, + "node_modules/@daml.js/daml-prim-GHC-Tuple-1.0.0": { + "resolved": "generated/daml-prim-GHC-Tuple-1.0.0", + "link": true + }, + "node_modules/@daml.js/daml-prim-GHC-Types-1.0.0": { + "resolved": "generated/daml-prim-GHC-Types-1.0.0", + "link": true + }, + "node_modules/@daml.js/daml-stdlib-DA-Date-Types-1.0.0": { + "resolved": "generated/daml-stdlib-DA-Date-Types-1.0.0", + "link": true + }, + "node_modules/@daml.js/daml-stdlib-DA-Internal-Down-1.0.0": { + "resolved": "generated/daml-stdlib-DA-Internal-Down-1.0.0", + "link": true + }, + "node_modules/@daml.js/daml-stdlib-DA-Logic-Types-1.0.0": { + "resolved": "generated/daml-stdlib-DA-Logic-Types-1.0.0", + "link": true + }, + "node_modules/@daml.js/daml-stdlib-DA-Monoid-Types-1.0.0": { + "resolved": "generated/daml-stdlib-DA-Monoid-Types-1.0.0", + "link": true + }, + "node_modules/@daml.js/daml-stdlib-DA-NonEmpty-Types-1.0.0": { + "resolved": "generated/daml-stdlib-DA-NonEmpty-Types-1.0.0", + "link": true + }, + "node_modules/@daml.js/daml-stdlib-DA-Random-Types-1.0.0": { + "resolved": "generated/daml-stdlib-DA-Random-Types-1.0.0", + "link": true + }, + "node_modules/@daml.js/daml-stdlib-DA-Semigroup-Types-1.0.0": { + "resolved": "generated/daml-stdlib-DA-Semigroup-Types-1.0.0", + "link": true + }, + "node_modules/@daml.js/daml-stdlib-DA-Set-Types-1.0.0": { + "resolved": "generated/daml-stdlib-DA-Set-Types-1.0.0", + "link": true + }, + "node_modules/@daml.js/daml-stdlib-DA-Stack-Types-1.0.0": { + "resolved": "generated/daml-stdlib-DA-Stack-Types-1.0.0", + "link": true + }, + "node_modules/@daml.js/daml-stdlib-DA-Time-Types-1.0.0": { + "resolved": "generated/daml-stdlib-DA-Time-Types-1.0.0", + "link": true + }, + "node_modules/@daml.js/daml-stdlib-DA-Validation-Types-1.0.0": { + "resolved": "generated/daml-stdlib-DA-Validation-Types-1.0.0", + "link": true + }, + "node_modules/@daml.js/ghc-stdlib-DA-Internal-Template-1.0.0": { + "resolved": "generated/ghc-stdlib-DA-Internal-Template-1.0.0", + "link": true + }, + "node_modules/@daml.js/quickstart-licensing-0.0.1": { + "resolved": "generated/quickstart-licensing-0.0.1", + "link": true + }, + "node_modules/@daml.js/splice-api-token-allocation-request-v1-1.0.0": { + "resolved": "generated/splice-api-token-allocation-request-v1-1.0.0", + "link": true + }, + "node_modules/@daml.js/splice-api-token-allocation-v1-1.0.0": { + "resolved": "generated/splice-api-token-allocation-v1-1.0.0", + "link": true + }, + "node_modules/@daml.js/splice-api-token-holding-v1-1.0.0": { + "resolved": "generated/splice-api-token-holding-v1-1.0.0", + "link": true + }, + "node_modules/@daml.js/splice-api-token-metadata-v1-1.0.0": { + "resolved": "generated/splice-api-token-metadata-v1-1.0.0", + "link": true + }, + "node_modules/@daml/types": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/@daml/types/-/types-3.4.11.tgz", + "integrity": "sha512-lGskPPTFVtJpLbHi1XoqG7/mnOU/zJcomQixoszEbtU7D63t7xDAW9yvtaQa7C1Uz70OOBhM1z72Se63GR3NXg==", + "license": "Apache-2.0", + "dependencies": { + "@mojotech/json-type-validation": "^3.1.0", + "@types/lodash": "^4.5", + "lodash": "^4.5" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/cookie": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz", + "integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^1.0.0", + "fastify-plugin": "^5.0.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/formbody": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@fastify/formbody/-/formbody-8.0.2.tgz", + "integrity": "sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-querystring": "^1.1.2", + "fastify-plugin": "^5.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, + "node_modules/@fastify/session": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@fastify/session/-/session-11.1.1.tgz", + "integrity": "sha512-nuKwTHxh3eJsI4NJeXoYVGzXUsg+kH1WfHgS7IofVyVhmjc+A6qGr+29WQy8hYZiNtmCjfG415COpf5xTBkW4Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.1", + "safe-stable-stringify": "^2.4.3" + } + }, + "node_modules/@mojotech/json-type-validation": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mojotech/json-type-validation/-/json-type-validation-3.1.0.tgz", + "integrity": "sha512-ThH2EbHEUCPMHhXAtmYcDi0gmV+PZak4uvuWBMiBDqUuz7gGQUrsE5o1J6kKNLDX5cXAPqsfJ7uTfTcNdCDXxA==", + "license": "MIT", + "dependencies": { + "lodash.isequal": "^4.5.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.13", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.13.tgz", + "integrity": "sha512-4Tm4ysZkexx6ZTX7knqSZTqPlNgIvXc7Ha0pd30I694/GD0KtJE2xrElycfPds0vCLFAqoKyIzBtOF1xrLo8KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.1.1", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.8.5", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz", + "integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/find-my-way": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", + "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/oauth4webapi": { + "version": "3.8.6", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz", + "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/openid-client": { + "version": "6.8.4", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.4.tgz", + "integrity": "sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==", + "license": "MIT", + "dependencies": { + "jose": "^6.2.2", + "oauth4webapi": "^3.8.5" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/quickstart/backend-js/package.json b/quickstart/backend-js/package.json new file mode 100644 index 00000000..d9ebdee2 --- /dev/null +++ b/quickstart/backend-js/package.json @@ -0,0 +1,36 @@ +{ + "name": "backend-js", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "workspaces": [ + "generated/*" + ], + "scripts": { + "prebuild": "openapi-typescript ${VENDORED_DIR:-../backend/src/main/resources/vendored}/token-metadata-v1.yaml -o src/token-standard/metadata.types.ts && openapi-typescript ${VENDORED_DIR:-../backend/src/main/resources/vendored}/allocation-v1.yaml -o src/token-standard/allocation.types.ts", + "build": "tsc -p .", + "start": "node dist/server.js", + "dev": "tsx watch src/server.ts" + }, + "dependencies": { + "@daml.js/quickstart-licensing-0.0.1": "*", + "@daml/types": "3.4.11", + "@fastify/cookie": "^11.0.2", + "@fastify/formbody": "^8.0.2", + "@fastify/session": "^11.1.1", + "fastify": "^5.8.5", + "jose": "^6.2.3", + "openid-client": "^6.8.4", + "pg": "^8.20.0", + "pino": "^10.3.1" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "@types/pg": "^8.20.0", + "openapi-typescript": "^7.13.0", + "tsx": "^4.21.0", + "typescript": "^5.9.3" + } +} diff --git a/quickstart/backend-js/src/auth/admin-gate.ts b/quickstart/backend-js/src/auth/admin-gate.ts new file mode 100644 index 00000000..357d749d --- /dev/null +++ b/quickstart/backend-js/src/auth/admin-gate.ts @@ -0,0 +1,21 @@ +import type { FastifyReply, FastifyRequest } from 'fastify' +import type { BackendConfig } from '../config.js' +import { verifyAdminJwt } from './jwt-admin.js' + +const hasBearerToken = (authHeader: string | undefined): boolean => + typeof authHeader === 'string' && /^Bearer\s+\S+/i.test(authHeader) + +// Accepts either a valid Bearer JWT or an admin session cookie. +// On failure, replies with 401 (no auth) or 403 (authenticated but not admin) and returns false. +export const checkAdmin = async (cfg: BackendConfig, req: FastifyRequest, reply: FastifyReply): Promise => { + const sessionIsAdmin = req.session.user?.isAdmin + if (sessionIsAdmin === true) return true + + const authHeader = req.headers['authorization'] + const authenticatedButNotAdmin = sessionIsAdmin === false || hasBearerToken(authHeader) + + if (cfg.authMode === 'oauth2' && await verifyAdminJwt(cfg, authHeader)) return true + + reply.code(authenticatedButNotAdmin ? 403 : 401).send({ message: authenticatedButNotAdmin ? 'forbidden' : 'unauthorized' }) + return false +} diff --git a/quickstart/backend-js/src/auth/cookies.ts b/quickstart/backend-js/src/auth/cookies.ts new file mode 100644 index 00000000..614bd489 --- /dev/null +++ b/quickstart/backend-js/src/auth/cookies.ts @@ -0,0 +1,3 @@ +export const SESSION_COOKIE = 'JSESSIONID' +export const XSRF_COOKIE = 'XSRF-TOKEN' +export const XSRF_HEADER = 'x-xsrf-token' diff --git a/quickstart/backend-js/src/auth/csrf.ts b/quickstart/backend-js/src/auth/csrf.ts new file mode 100644 index 00000000..4fe02956 --- /dev/null +++ b/quickstart/backend-js/src/auth/csrf.ts @@ -0,0 +1,32 @@ +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify' +import { randomBytes } from 'node:crypto' +import { XSRF_COOKIE, XSRF_HEADER } from './cookies.js' + +const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']) + +const ensureToken = (req: FastifyRequest, reply: FastifyReply): string => { + const existing = req.cookies[XSRF_COOKIE] + if (existing !== undefined && existing !== '') return existing + const fresh = randomBytes(32).toString('hex') + reply.setCookie(XSRF_COOKIE, fresh, { httpOnly: false, sameSite: 'lax', path: '/', secure: false }) + return fresh +} + +export const registerCsrf = async (app: FastifyInstance): Promise => { + app.addHook('onRequest', async (req, reply) => { + if (SAFE_METHODS.has(req.method)) { + ensureToken(req, reply) + return + } + // Bearer-authenticated calls are stateless service-to-service traffic (e.g. + // register-app-user-tenant); CSRF is a session-cookie defense and doesn't apply. + // Mirrors Spring Security's default of bypassing CSRF for JWT bearer auth. + const authHeader = req.headers['authorization'] + if (typeof authHeader === 'string' && /^Bearer\s+\S+/i.test(authHeader)) return + const cookieToken = req.cookies[XSRF_COOKIE] + const headerToken = req.headers[XSRF_HEADER] + if (cookieToken === undefined || headerToken === undefined || cookieToken !== headerToken) { + return reply.code(403).send({ message: 'csrf_mismatch' }) + } + }) +} diff --git a/quickstart/backend-js/src/auth/jwt-admin.ts b/quickstart/backend-js/src/auth/jwt-admin.ts new file mode 100644 index 00000000..eed8f4e7 --- /dev/null +++ b/quickstart/backend-js/src/auth/jwt-admin.ts @@ -0,0 +1,27 @@ +import { createRemoteJWKSet, jwtVerify } from 'jose' +import type { BackendConfig } from '../config.js' + +const jwksCache = new Map>() + +const getJwks = (issuer: string): ReturnType => { + const existing = jwksCache.get(issuer) + if (existing !== undefined) return existing + const jwksUri = `${issuer.replace(/\/$/, '')}/protocol/openid-connect/certs` + const jwks = createRemoteJWKSet(new URL(jwksUri)) + jwksCache.set(issuer, jwks) + return jwks +} + +export const verifyAdminJwt = async (cfg: BackendConfig, authHeader: string | undefined): Promise => { + if (cfg.authMode !== 'oauth2' || cfg.oauth2 === undefined) return false + if (authHeader === undefined || !authHeader.startsWith('Bearer ')) return false + const token = authHeader.slice(7) + const issuer = cfg.oauth2.issuerUrl + if (issuer === '') return false + try { + await jwtVerify(token, getJwks(issuer), { issuer }) + return true + } catch { + return false + } +} diff --git a/quickstart/backend-js/src/auth/oauth2-registry.ts b/quickstart/backend-js/src/auth/oauth2-registry.ts new file mode 100644 index 00000000..a596e206 --- /dev/null +++ b/quickstart/backend-js/src/auth/oauth2-registry.ts @@ -0,0 +1,53 @@ +import * as openid from 'openid-client' + +export interface RegistrationEntry { + registrationId: string + tenantId: string + clientId: string + issuerUrl: string + config: openid.Configuration +} + +export class OAuth2Registry { + private readonly entries = new Map() + + async register(tenantId: string, clientId: string, issuerUrl: string): Promise { + const registrationId = tenantId === 'AppProvider' ? 'AppProvider' : `${tenantId}-${clientId}` + if (this.entries.has(registrationId)) { + throw new Error(`Registration already exists: ${registrationId}`) + } + // openid-client v6 rejects plain-HTTP issuers by default. Spring's default HTTP client + // (used by the Java backend) doesn't enforce HTTPS, so opt into allowInsecureRequests for + // http:// issuers to match. The execute hook propagates to all subsequent calls made + // through the returned Configuration. + // + // allowInsecureRequests is annotated @deprecated by the library to make it stand out as a + // safety override (not because it's going away or has a replacement); accepted here. + const url = new URL(issuerUrl) + const options: openid.DiscoveryRequestOptions | undefined = + url.protocol === 'http:' ? { execute: [openid.allowInsecureRequests] } : undefined + const config = await openid.discovery(url, clientId, undefined, undefined, options) + this.entries.set(registrationId, { registrationId, tenantId, clientId, issuerUrl, config }) + return registrationId + } + + get(registrationId: string): RegistrationEntry | undefined { + return this.entries.get(registrationId) + } + + list(): RegistrationEntry[] { + return [...this.entries.values()] + } + + removeByTenantId(tenantId: string): void { + const keys = [...this.entries.entries()] + .filter(([, e]) => e.tenantId === tenantId) + .map(([k]) => k) + if (keys.length === 0) throw new Error(`No registrations for tenant: ${tenantId}`) + keys.forEach((k) => this.entries.delete(k)) + } + + loginUrl(registrationId: string): string { + return `/oauth2/authorization/${registrationId}` + } +} diff --git a/quickstart/backend-js/src/auth/oauth2.ts b/quickstart/backend-js/src/auth/oauth2.ts new file mode 100644 index 00000000..6a8de17e --- /dev/null +++ b/quickstart/backend-js/src/auth/oauth2.ts @@ -0,0 +1,91 @@ +import type { FastifyInstance } from 'fastify' +import * as openid from 'openid-client' +import { randomBytes } from 'node:crypto' +import type { BackendConfig } from '../config.js' +import type { TenantRepository } from '../tenants/repository.js' +import { OAuth2Registry } from './oauth2-registry.js' +import { XSRF_COOKIE } from './cookies.js' +import { resolveTestModePartyId } from './test-mode-party.js' + +const baseUrl = (req: { headers: Record }, cfg: BackendConfig): string => { + const proto = (req.headers['x-forwarded-proto'] as string | undefined) ?? 'http' + const host = (req.headers['x-forwarded-host'] as string | undefined) ?? `localhost:${cfg.port}` + return `${proto}://${host}` +} + +export const initOAuth2Registry = async (cfg: BackendConfig): Promise => { + const registry = new OAuth2Registry() + if (cfg.authMode !== 'oauth2' || cfg.oauth2 === undefined) return registry + await registry.register('AppProvider', cfg.oauth2.backendOidcClientId, cfg.oauth2.issuerUrl) + return registry +} + +export const registerOAuth2 = async (app: FastifyInstance, cfg: BackendConfig, registry: OAuth2Registry, tenants: TenantRepository): Promise => { + if (cfg.authMode !== 'oauth2') return + + app.get<{ Params: { registrationId: string } }>('/oauth2/authorization/:registrationId', async (req, reply) => { + const entry = registry.get(req.params.registrationId) + if (entry === undefined) { reply.code(404); return { message: 'unknown_registration' } } + const codeVerifier = openid.randomPKCECodeVerifier() + const codeChallenge = await openid.calculatePKCECodeChallenge(codeVerifier) + const state = randomBytes(16).toString('hex') + const nonce = randomBytes(16).toString('hex') + req.session.oauthState = { state, codeVerifier, registrationId: req.params.registrationId, nonce } + const redirectUri = `${baseUrl(req, cfg)}/login/oauth2/code/${req.params.registrationId}` + const url = openid.buildAuthorizationUrl(entry.config, { + redirect_uri: redirectUri, + scope: 'openid', + code_challenge: codeChallenge, + code_challenge_method: 'S256', + state, + nonce + }) + return reply.redirect(url.href) + }) + + app.get<{ Params: { registrationId: string }; Querystring: Record }>( + '/login/oauth2/code/:registrationId', + async (req, reply) => { + const entry = registry.get(req.params.registrationId) + const stored = req.session.oauthState + if (entry === undefined || stored === undefined || stored.registrationId !== req.params.registrationId) { + reply.code(400); return { message: 'invalid_state' } + } + // Use the raw request URL to preserve the exact encoding of query parameters. + const currentUrl = new URL(req.raw.url ?? req.url, baseUrl(req, cfg)) + const tokens = await openid.authorizationCodeGrant(entry.config, currentUrl, { + pkceCodeVerifier: stored.codeVerifier, + expectedState: stored.state, + expectedNonce: stored.nonce + }) + const rawClaims = tokens.claims() + const claims: Record = rawClaims !== undefined ? (rawClaims as Record) : {} + const name = (claims['name'] as string | undefined) ?? + (claims['preferred_username'] as string | undefined) ?? + (claims['sub'] as string | undefined) ?? 'unknown' + const isAppProvider = entry.tenantId === 'AppProvider' + const roles: string[] = isAppProvider + ? ['ROLE_ADMIN', 'SCOPE_openid'] + : ['ROLE_USER', 'SCOPE_openid'] + const tenantPartyId = tenants.get(entry.tenantId)?.partyId ?? '' + req.session.user = { + name, + tenantId: entry.tenantId, + partyId: resolveTestModePartyId(cfg, claims, tenantPartyId), + userId: claims['sub'] as string | undefined, + idTokenClaims: claims, + roles, + isAdmin: isAppProvider + } + req.session.oauthState = undefined + // Mirrors Spring Security's CSRF workaround for oauth2 callback: ensure an XSRF-TOKEN + // cookie exists, but do not overwrite a token the client already holds (preserves + // tokens from in-flight tabs that were issued before login). + if (req.cookies[XSRF_COOKIE] === undefined || req.cookies[XSRF_COOKIE] === '') { + const csrfToken = randomBytes(32).toString('hex') + reply.setCookie(XSRF_COOKIE, csrfToken, { httpOnly: false, sameSite: 'lax', path: '/', secure: false }) + } + return reply.redirect('/') + } + ) +} diff --git a/quickstart/backend-js/src/auth/session.ts b/quickstart/backend-js/src/auth/session.ts new file mode 100644 index 00000000..7eeb7630 --- /dev/null +++ b/quickstart/backend-js/src/auth/session.ts @@ -0,0 +1,22 @@ +import type { FastifyInstance } from 'fastify' +import cookie from '@fastify/cookie' +import session from '@fastify/session' +import { randomBytes } from 'node:crypto' +import { SESSION_COOKIE } from './cookies.js' + +declare module 'fastify' { + interface Session { + user?: { name: string; tenantId: string; partyId: string; userId?: string; idTokenClaims?: Record; roles?: string[]; isAdmin?: boolean } + oauthState?: { state: string; codeVerifier: string; registrationId: string; nonce: string } + } +} + +export const registerSession = async (app: FastifyInstance): Promise => { + await app.register(cookie) + await app.register(session, { + secret: process.env['SESSION_SECRET'] ?? randomBytes(32).toString('hex'), + cookieName: SESSION_COOKIE, + cookie: { httpOnly: true, sameSite: 'lax', path: '/', secure: false }, + saveUninitialized: false + }) +} diff --git a/quickstart/backend-js/src/auth/shared-secret.ts b/quickstart/backend-js/src/auth/shared-secret.ts new file mode 100644 index 00000000..54d4061a --- /dev/null +++ b/quickstart/backend-js/src/auth/shared-secret.ts @@ -0,0 +1,31 @@ +import type { FastifyInstance } from 'fastify' +import type { BackendConfig } from '../config.js' +import type { TenantRepository } from '../tenants/repository.js' +import { resolveTestModePartyId } from './test-mode-party.js' + +export const registerSharedSecret = async (app: FastifyInstance, cfg: BackendConfig, tenants: TenantRepository): Promise => { + if (cfg.authMode !== 'shared-secret') return + + app.post<{ Body: { username?: string } }>('/login', async (req, reply) => { + const username = req.body?.username + if (username === undefined || username === '') { reply.redirect('/login?error=missing_username'); return } + const matchedTenant = tenants.list().find(t => t.users?.includes(username)) + if (matchedTenant === undefined) { reply.redirect('/login?error=unknown_user'); return } + req.session.user = { + name: username, + tenantId: matchedTenant.tenantId, + partyId: resolveTestModePartyId(cfg, req.session.user?.idTokenClaims, matchedTenant.partyId), + userId: username, + roles: matchedTenant.internal ? ['ROLE_ADMIN'] : ['ROLE_USER'], + isAdmin: matchedTenant.internal + } + reply.redirect('/') + }) +} + +export const registerLogout = async (app: FastifyInstance): Promise => { + app.post('/logout', async (req, reply) => { + await req.session.destroy() + reply.code(204).send() + }) +} diff --git a/quickstart/backend-js/src/auth/test-mode-party.ts b/quickstart/backend-js/src/auth/test-mode-party.ts new file mode 100644 index 00000000..0a11f133 --- /dev/null +++ b/quickstart/backend-js/src/auth/test-mode-party.ts @@ -0,0 +1,15 @@ +import type { BackendConfig } from '../config.js' + +// Mirrors Spring's `test` profile (OAuth2AuthenticationSuccessHandler): in TEST_MODE the +// JWT's `party_id` claim wins over the tenant-registered party so each integration-test +// run gets a fresh AppUser party. CAUTION: not for production — a forged claim would +// otherwise let any caller act as any party. +export const resolveTestModePartyId = ( + cfg: BackendConfig, + claims: Record | undefined, + fallback: string +): string => { + if (!cfg.testMode || claims === undefined) return fallback + const claim = claims['party_id'] + return typeof claim === 'string' && claim !== '' ? claim : fallback +} diff --git a/quickstart/backend-js/src/canton/auth.ts b/quickstart/backend-js/src/canton/auth.ts new file mode 100644 index 00000000..86cf320a --- /dev/null +++ b/quickstart/backend-js/src/canton/auth.ts @@ -0,0 +1,28 @@ +import type { BackendConfig } from '../config.js' + +interface CachedToken { token: string; expiresAt: number } + +export class CantonTokenProvider { + private cached?: CachedToken + + constructor(private readonly cfg: BackendConfig) {} + + async getToken(): Promise { + if (this.cfg.authMode === 'shared-secret') return this.cfg.sharedSecretToken + if (this.cfg.authMode !== 'oauth2' || this.cfg.oauth2 === undefined) return undefined + const now = Date.now() + if (this.cached !== undefined && this.cached.expiresAt > now + 30_000) return this.cached.token + + const tokenUrl = `${this.cfg.oauth2.issuerUrl.replace(/\/$/, '')}/protocol/openid-connect/token` + const body = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: this.cfg.oauth2.backendClientId, + client_secret: this.cfg.oauth2.backendClientSecret + }) + const res = await fetch(tokenUrl, { method: 'POST', body, headers: { 'content-type': 'application/x-www-form-urlencoded' } }) + if (!res.ok) throw new Error(`token endpoint ${res.status}: ${await res.text()}`) + const json = await res.json() as { access_token: string; expires_in: number } + this.cached = { token: json.access_token, expiresAt: now + json.expires_in * 1000 } + return json.access_token + } +} diff --git a/quickstart/backend-js/src/canton/commands.ts b/quickstart/backend-js/src/canton/commands.ts new file mode 100644 index 00000000..e924cb5e --- /dev/null +++ b/quickstart/backend-js/src/canton/commands.ts @@ -0,0 +1,69 @@ +import { randomBytes } from 'node:crypto' +import type * as damlTypes from '@daml/types' +import type { BackendConfig } from '../config.js' +import type { LedgerApi, DisclosedContract } from './ledger.js' + +export interface SubmitContext { + actAs: string + userId: string + commandIdOverride?: string +} + +export const generateCommandId = (override?: string): string => + override !== undefined && override !== '' ? override : `qs-js-${randomBytes(8).toString('hex')}` + +export const createContract = async ( + ledger: LedgerApi, + ctx: SubmitContext, + template: damlTypes.Template, + payload: T +): Promise => { + return ledger.submitAndWaitForTransaction({ + commandId: generateCommandId(ctx.commandIdOverride), + actAs: [ctx.actAs], + readAs: [ctx.actAs], + userId: ctx.userId, + commands: [{ + CreateCommand: { + templateId: template.templateId, + createArguments: template.encode(payload) + } + }] + }) +} + +export const exerciseChoice = async ( + ledger: LedgerApi, + ctx: SubmitContext, + template: damlTypes.TemplateOrInterface, + choice: damlTypes.Choice, + contractId: string, + args: C, + disclosed?: DisclosedContract[] +): Promise => { + return ledger.submitAndWaitForTransaction({ + commandId: generateCommandId(ctx.commandIdOverride), + actAs: [ctx.actAs], + readAs: [ctx.actAs], + userId: ctx.userId, + commands: [{ + ExerciseCommand: { + templateId: template.templateId, + contractId, + choice: choice.choiceName, + choiceArgument: choice.argumentEncode(args) + } + }], + disclosedContracts: disclosed + }) +} + +export const submitContextFromSession = ( + cfg: BackendConfig, + party: string, + commandIdQuery: string | undefined +): SubmitContext => ({ + actAs: party, + userId: cfg.appProviderUserId, + commandIdOverride: commandIdQuery +}) diff --git a/quickstart/backend-js/src/canton/ledger.ts b/quickstart/backend-js/src/canton/ledger.ts new file mode 100644 index 00000000..dcadc4d5 --- /dev/null +++ b/quickstart/backend-js/src/canton/ledger.ts @@ -0,0 +1,44 @@ +import type { BackendConfig } from '../config.js' +import type { CantonTokenProvider } from './auth.js' + +export interface DisclosedContract { + contractId: string + createdEventBlob: string + synchronizerId: string + templateId: string +} + +export interface Command { + CreateCommand?: { templateId: string; createArguments: unknown } + ExerciseCommand?: { templateId: string; contractId: string; choice: string; choiceArgument: unknown } +} + +export interface SubmitArgs { + commandId: string + actAs: string[] + readAs?: string[] + userId: string + commands: Command[] + disclosedContracts?: DisclosedContract[] +} + +export class LedgerApi { + constructor(private readonly cfg: BackendConfig, private readonly tokens: CantonTokenProvider) {} + + private async post(path: string, body: unknown): Promise { + const token = await this.tokens.getToken() + const headers: Record = { 'content-type': 'application/json' } + if (token !== undefined) headers['authorization'] = `Bearer ${token}` + const res = await fetch(`${this.cfg.ledgerJsonApiBaseUrl}${path}`, { method: 'POST', headers, body: JSON.stringify(body) }) + if (!res.ok) throw new Error(`${path} ${res.status}: ${await res.text()}`) + return await res.json() as T + } + + async submitAndWaitForTransaction(args: SubmitArgs): Promise { + return this.post('/v2/commands/submit-and-wait-for-transaction', { commands: args }) + } + + async submitAndWaitForTransactionTree(args: SubmitArgs): Promise { + return this.post('/v2/commands/submit-and-wait-for-transaction-tree', { commands: args }) + } +} diff --git a/quickstart/backend-js/src/config.ts b/quickstart/backend-js/src/config.ts new file mode 100644 index 00000000..187dd6a1 --- /dev/null +++ b/quickstart/backend-js/src/config.ts @@ -0,0 +1,74 @@ +export interface BackendConfig { + port: number + registryBaseUri: string + ledgerHost: string + ledgerPort: number + ledgerJsonApiBaseUrl: string + postgres: { host: string; port: number; database: string; user: string; password: string } + authMode: 'oauth2' | 'shared-secret' + testMode: boolean + appProviderParty: string + appProviderUserId: string + // Static bearer token used in shared-secret mode (provided by the onboarding volume). + sharedSecretToken?: string + oauth2?: { + backendClientId: string + backendClientSecret: string + backendOidcClientId: string + issuerUrl: string + appUserBackendOidcClientId: string + appUserIssuerUrl: string + } +} + +const required = (name: string): string => { + const v = process.env[name] + if (v === undefined || v === '') throw new Error(`Missing env var: ${name}`) + return v +} + +const optional = (name: string): string | undefined => { + const v = process.env[name] + return v === undefined || v === '' ? undefined : v +} + +export const loadConfig = (): BackendConfig => { + const ledgerHost = required('LEDGER_HOST') + const ledgerPort = Number(required('LEDGER_PORT')) + const authMode: 'oauth2' | 'shared-secret' = required('SPRING_PROFILES_ACTIVE').includes('oauth2') + ? 'oauth2' + : 'shared-secret' + + return { + port: Number(required('BACKEND_PORT')), + registryBaseUri: required('REGISTRY_BASE_URI'), + ledgerHost, + ledgerPort, + ledgerJsonApiBaseUrl: `http://${ledgerHost}:${ledgerPort}`, + postgres: { + host: required('POSTGRES_HOST'), + port: Number(required('POSTGRES_PORT')), + database: required('POSTGRES_DATABASE'), + user: required('POSTGRES_USERNAME'), + password: required('POSTGRES_PASSWORD') + }, + authMode, + testMode: process.env['TEST_MODE'] === 'on', + appProviderParty: required('APP_PROVIDER_PARTY'), + // OAuth2: the Keycloak `sub` claim is the user's UUID (AUTH_APP_PROVIDER_BACKEND_USER_ID). + // Shared-secret: the JWT subject is the username (AUTH_APP_PROVIDER_BACKEND_USER_NAME). + // The JSON Ledger API rejects requests where commands.userId disagrees with the token's userId claim. + appProviderUserId: authMode === 'shared-secret' + ? required('AUTH_APP_PROVIDER_BACKEND_USER_NAME') + : (optional('AUTH_APP_PROVIDER_BACKEND_USER_ID') ?? 'AppId'), + sharedSecretToken: authMode === 'shared-secret' ? optional('APP_PROVIDER_BACKEND_USER_TOKEN') : undefined, + oauth2: authMode === 'oauth2' ? { + backendClientId: required('AUTH_APP_PROVIDER_BACKEND_CLIENT_ID'), + backendClientSecret: required('AUTH_APP_PROVIDER_BACKEND_SECRET'), + backendOidcClientId: required('AUTH_APP_PROVIDER_BACKEND_OIDC_CLIENT_ID'), + issuerUrl: required('AUTH_APP_PROVIDER_ISSUER_URL'), + appUserBackendOidcClientId: optional('AUTH_APP_USER_BACKEND_OIDC_CLIENT_ID') ?? '', + appUserIssuerUrl: optional('AUTH_APP_USER_ISSUER_URL') ?? '' + } : undefined + } +} diff --git a/quickstart/backend-js/src/domain/licensing/mappers.ts b/quickstart/backend-js/src/domain/licensing/mappers.ts new file mode 100644 index 00000000..af96580d --- /dev/null +++ b/quickstart/backend-js/src/domain/licensing/mappers.ts @@ -0,0 +1,74 @@ +import type { LicenseWithRenewalRows } from './repository.js' + +type P = Record + +const str = (p: P, k: string): string => p[k] as string +const num = (p: P, k: string): number => Number(p[k]) +const meta = (p: P, k: string): { data: Record } => { + const m = p[k] as { values?: Record } + return { data: m?.values ?? {} } +} + +export const mapAppInstallRequest = (contractId: string, payload: P) => ({ + contractId, + provider: str(payload, 'provider'), + user: str(payload, 'user'), + meta: meta(payload, 'meta') +}) + +export const mapAppInstall = (contractId: string, payload: P) => ({ + contractId, + provider: str(payload, 'provider'), + user: str(payload, 'user'), + meta: meta(payload, 'meta'), + numLicensesCreated: num(payload, 'numLicensesCreated') +}) + +export const mapLicenseRenewalRequest = (contractId: string, payload: P, allocationCid?: string) => { + // Daml Int round-trips as a string in the JSON Ledger API to preserve 64-bit precision, + // so use BigInt and reduce before casting back to Number for the final "N days" string. + const relTime = payload['licenseExtensionDuration'] as { microseconds: string } | undefined + const micros = BigInt(relTime?.microseconds ?? '0') + const approximateDays = Number(micros / (1_000_000n * 3600n * 24n)) + ' days' + const now = Date.now() + const prepareUntil = str(payload, 'prepareUntil') + const settleBefore = str(payload, 'settleBefore') + return { + contractId, + provider: str(payload, 'provider'), + user: str(payload, 'user'), + licenseNum: num(payload, 'licenseNum'), + licenseFeeAmount: parseFloat(payload['licenseFeeAmount'] as string), + licenseFeeInstrument: null, + licenseExtensionDuration: approximateDays, + prepareUntil, + settleBefore, + requestedAt: str(payload, 'requestedAt'), + description: str(payload, 'description'), + requestId: str(payload, 'requestId'), + allocationCid: allocationCid ?? null, + prepareDeadlinePassed: new Date(prepareUntil).getTime() <= now, + settleDeadlinePassed: new Date(settleBefore).getTime() <= now + } +} + +export const mapLicense = (row: LicenseWithRenewalRows) => { + const lp = row.licensePayload as P + const now = Date.now() + const expiresAt = str(lp, 'expiresAt') + const params = lp['params'] as P + return { + contractId: row.licenseContractId, + provider: str(lp, 'provider'), + user: str(lp, 'user'), + params: { + meta: meta(params, 'meta') + }, + expiresAt, + licenseNum: num(lp, 'licenseNum'), + isExpired: new Date(expiresAt).getTime() <= now, + renewalRequests: row.renewals + .map(r => mapLicenseRenewalRequest(r.contractId, r.payload as P, r.allocationCid)) + .sort((a, b) => new Date(a.requestedAt).getTime() - new Date(b.requestedAt).getTime()) + } +} diff --git a/quickstart/backend-js/src/domain/licensing/repository.ts b/quickstart/backend-js/src/domain/licensing/repository.ts new file mode 100644 index 00000000..7ce972d2 --- /dev/null +++ b/quickstart/backend-js/src/domain/licensing/repository.ts @@ -0,0 +1,83 @@ +import type pg from 'pg' +import { findActive, findActiveByContractId } from '../../pqs/contracts.js' + +// Template IDs as used by PQS active() function (module:template, no package hash). +// These match Utils.getTemplateIdByClass(Clazz).qualifiedName() from the Java backend. +export const TEMPLATE_IDS = { + AppInstall: 'Licensing.AppInstall:AppInstall', + AppInstallRequest: 'Licensing.AppInstall:AppInstallRequest', + License: 'Licensing.License:License', + LicenseRenewalRequest: 'Licensing.License:LicenseRenewalRequest', + AllocationRequest: 'Splice.Api.Token.AllocationRequestV1:AllocationRequest', + Allocation: 'Splice.Api.Token.AllocationV1:Allocation' +} as const + +export interface LicenseRow { contract_id: string; license_payload: Record } +export interface LicenseWithRenewalRows { + licenseContractId: string + licensePayload: Record + renewals: Array<{ contractId: string; payload: Record; allocationCid?: string }> +} + +export const findActiveLicenses = async (pool: pg.Pool, party: string): Promise => { + const sql = ` + SELECT license.contract_id AS license_contract_id, + license.payload AS license_payload, + renewal.contract_id AS renewal_contract_id, + renewal.payload AS renewal_payload, + allocation.contract_id AS allocation_contract_id + FROM active($1) license + LEFT JOIN active($2) renewal ON + license.payload->>'licenseNum' = renewal.payload->>'licenseNum' + AND license.payload->>'user' = renewal.payload->>'user' + LEFT JOIN active($3) allocation ON + renewal.payload->>'requestId' = allocation.payload->'allocation'->'settlement'->'settlementRef'->>'id' + AND renewal.payload->>'user' = allocation.payload->'allocation'->'transferLeg'->>'sender' + WHERE license.payload->>'user' = $4 OR license.payload->>'provider' = $4 + ORDER BY license.contract_id + ` + const res = await pool.query(sql, [ + TEMPLATE_IDS.License, + TEMPLATE_IDS.LicenseRenewalRequest, + TEMPLATE_IDS.Allocation, + party + ]) + const map = new Map() + for (const row of res.rows) { + const licId: string = row.license_contract_id + let entry = map.get(licId) + if (entry === undefined) { + entry = { licenseContractId: licId, licensePayload: row.license_payload, renewals: [] } + map.set(licId, entry) + } + if (row.renewal_contract_id !== null) { + entry.renewals.push({ + contractId: row.renewal_contract_id, + payload: row.renewal_payload, + allocationCid: row.allocation_contract_id ?? undefined + }) + } + } + return [...map.values()] +} + +export const findLicenseById = (pool: pg.Pool, contractId: string) => + findActiveByContractId(pool, TEMPLATE_IDS.License, contractId) + +export const findActiveLicenseRenewalRequestById = (pool: pg.Pool, contractId: string) => + findActiveByContractId(pool, TEMPLATE_IDS.LicenseRenewalRequest, contractId) + +export const findActiveAllocationRequestById = (pool: pg.Pool, contractId: string) => + findActiveByContractId(pool, TEMPLATE_IDS.AllocationRequest, contractId) + +export const findAppInstallById = (pool: pg.Pool, contractId: string) => + findActiveByContractId(pool, TEMPLATE_IDS.AppInstall, contractId) + +export const findAppInstallRequestById = (pool: pg.Pool, contractId: string) => + findActiveByContractId(pool, TEMPLATE_IDS.AppInstallRequest, contractId) + +export const findActiveAppInstalls = (pool: pg.Pool) => + findActive(pool, TEMPLATE_IDS.AppInstall) + +export const findActiveAppInstallRequests = (pool: pg.Pool) => + findActive(pool, TEMPLATE_IDS.AppInstallRequest) diff --git a/quickstart/backend-js/src/domain/licensing/service.ts b/quickstart/backend-js/src/domain/licensing/service.ts new file mode 100644 index 00000000..5a891e5a --- /dev/null +++ b/quickstart/backend-js/src/domain/licensing/service.ts @@ -0,0 +1,323 @@ +import { randomUUID } from 'node:crypto' +import type pg from 'pg' +import type * as damlTypes from '@daml/types' +import { AppInstall, AppInstallRequest } from '@daml.js/quickstart-licensing-0.0.1/lib/Licensing/AppInstall/module.js' +import { License, LicenseRenewalRequest } from '@daml.js/quickstart-licensing-0.0.1/lib/Licensing/License/module.js' +import { AllocationRequest } from '@daml.js/splice-api-token-allocation-request-v1-1.0.0/lib/Splice/Api/Token/AllocationRequestV1/module.js' +import type { Allocation } from '@daml.js/splice-api-token-allocation-v1-1.0.0/lib/Splice/Api/Token/AllocationV1/module.js' +import type { AnyContract, AnyValue } from '@daml.js/splice-api-token-metadata-v1-1.0.0/lib/Splice/Api/Token/MetadataV1/module.js' +import type { BackendConfig } from '../../config.js' +import type { LedgerApi } from '../../canton/ledger.js' +import { exerciseChoice, submitContextFromSession } from '../../canton/commands.js' +import type { TokenStandardClient } from '../../token-standard/client.js' +import type { TenantRepository } from '../../tenants/repository.js' +import { + findActiveLicenses, + findLicenseById, + findActiveLicenseRenewalRequestById, + findActiveAllocationRequestById, + findAppInstallById, + findAppInstallRequestById, + findActiveAppInstalls, + findActiveAppInstallRequests +} from './repository.js' +import { mapAppInstallRequest, mapAppInstall, mapLicense } from './mappers.js' + +const cidOf = (s: string): damlTypes.ContractId => s as damlTypes.ContractId + +export class LicensingService { + constructor( + private readonly cfg: BackendConfig, + private readonly pool: pg.Pool, + private readonly ledger: LedgerApi, + private readonly tokenStandard: TokenStandardClient, + private readonly tenants: TenantRepository + ) {} + + async listAppInstallRequests(sessionParty: string) { + const contracts = await findActiveAppInstallRequests(this.pool) + return contracts + .filter(c => { + const p = c.payload as Record + return p['user'] === sessionParty || p['provider'] === sessionParty + }) + .map(c => mapAppInstallRequest(c.contractId, c.payload as Record)) + } + + async acceptAppInstallRequest(contractId: string, commandId: string | undefined, body: { installMeta?: { data?: Record }; meta?: { data?: Record } }) { + const contract = await findAppInstallRequestById(this.pool, contractId) + if (contract === undefined) return { status: 404 as const, message: `AppInstallRequest not found for contract ${contractId}` } + const ctx = submitContextFromSession(this.cfg, this.cfg.appProviderParty, commandId) + await exerciseChoice( + this.ledger, ctx, + AppInstallRequest, AppInstallRequest.AppInstallRequest_Accept, + contractId, + { + installMeta: { values: body.installMeta?.data ?? {} }, + meta: { values: body.meta?.data ?? {} } + } + ) + const payload = contract.payload as Record + return { + status: 201 as const, + body: { + provider: payload['provider'] as string, + user: payload['user'] as string, + meta: { data: body.installMeta?.data ?? {} }, + numLicensesCreated: 0 + } + } + } + + async rejectAppInstallRequest(contractId: string, commandId: string | undefined, body: { meta?: { data?: Record } }) { + const contract = await findAppInstallRequestById(this.pool, contractId) + if (contract === undefined) return { status: 404 as const, message: `AppInstallRequest not found for contract ${contractId}` } + const ctx = submitContextFromSession(this.cfg, this.cfg.appProviderParty, commandId) + await exerciseChoice( + this.ledger, ctx, + AppInstallRequest, AppInstallRequest.AppInstallRequest_Reject, + contractId, + { meta: { values: body.meta?.data ?? {} } } + ) + return { status: 204 as const } + } + + async listAppInstalls(sessionParty: string) { + const contracts = await findActiveAppInstalls(this.pool) + return contracts + .filter(c => { + const p = c.payload as Record + return p['provider'] === sessionParty || p['user'] === sessionParty + }) + .map(c => mapAppInstall(c.contractId, c.payload as Record)) + } + + async createLicense(contractId: string, commandId: string | undefined, body: { params?: { meta?: { data?: Record } } }) { + const contract = await findAppInstallById(this.pool, contractId) + if (contract === undefined) return { status: 404 as const, message: `AppInstall not found for contract ${contractId}` } + const payload = contract.payload as Record + if (payload['provider'] !== this.cfg.appProviderParty) { + return { status: 403 as const, message: 'Insufficient permissions' } + } + const ctx = submitContextFromSession(this.cfg, this.cfg.appProviderParty, commandId) + const result = await exerciseChoice( + this.ledger, ctx, + AppInstall, AppInstall.AppInstall_CreateLicense, + contractId, + { params: { meta: { values: body.params?.meta?.data ?? {} } } } + ) + const licenseId = findCreatedContract(result, ':Licensing.License:License') + if (licenseId === null) return { status: 500 as const, message: 'Failed to locate created License in transaction events' } + return { + status: 201 as const, + body: { installId: contractId, licenseId } + } + } + + async cancelAppInstall(contractId: string, commandId: string | undefined, sessionParty: string, body: { meta?: { data?: Record } }) { + const contract = await findAppInstallById(this.pool, contractId) + if (contract === undefined) return { status: 404 as const, message: `AppInstall not found for contract ${contractId}` } + const payload = contract.payload as Record + if (sessionParty !== payload['user'] && sessionParty !== payload['provider']) { + return { status: 403 as const, message: `party ${sessionParty} is not the user nor provider` } + } + const ctx = submitContextFromSession(this.cfg, this.cfg.appProviderParty, commandId) + await exerciseChoice( + this.ledger, ctx, + AppInstall, AppInstall.AppInstall_Cancel, + contractId, + { + actor: this.cfg.appProviderParty, + meta: { values: body.meta?.data ?? {} } + } + ) + return { status: 204 as const } + } + + async listLicenses(sessionParty: string) { + const rows = await findActiveLicenses(this.pool, sessionParty) + return rows + .map(mapLicense) + .sort((a, b) => { + const u = a.user.localeCompare(b.user) + return u !== 0 ? u : a.licenseNum - b.licenseNum + }) + } + + async renewLicense(contractId: string, commandId: string | undefined, body: { + licenseFeeCc: number + licenseExtensionDuration: string + prepareUntilDuration: string + settleBeforeDuration: string + description: string + }) { + const [adminId, license] = await Promise.all([ + this.tokenStandard.getRegistryAdminId(), + findLicenseById(this.pool, contractId) + ]) + if (license === undefined) return { status: 404 as const, message: `License not found for contract ${contractId}` } + const nowMs = Date.now() + const durationToMicros = (iso: string): string => { + const ms = parseDurationMs(iso) + return String(ms * 1000) + } + const ctx = submitContextFromSession(this.cfg, this.cfg.appProviderParty, commandId) + await exerciseChoice( + this.ledger, ctx, + License, License.License_Renew, + contractId, + { + requestId: randomUUID(), + licenseFeeInstrumentId: { admin: adminId.adminId, id: 'Amulet' }, + licenseFeeAmount: String(body.licenseFeeCc), + licenseExtensionDuration: { microseconds: durationToMicros(body.licenseExtensionDuration) }, + requestedAt: toIsoMicros(nowMs), + prepareUntil: toIsoMicros(nowMs + parseDurationMs(body.prepareUntilDuration)), + settleBefore: toIsoMicros(nowMs + parseDurationMs(body.settleBeforeDuration)), + description: body.description + } + ) + return { status: 201 as const } + } + + async completeLicenseRenewal(contractId: string, commandId: string | undefined, body: { + renewalRequestContractId: string + allocationContractId: string + }) { + const [choiceCtx, renewal] = await Promise.all([ + this.tokenStandard.getAllocationTransferContext(body.allocationContractId), + findActiveLicenseRenewalRequestById(this.pool, body.renewalRequestContractId) + ]) + if (choiceCtx === null || choiceCtx === undefined) { + return { status: 404 as const, message: `Transfer context not found for allocation ${body.allocationContractId}` } + } + if (renewal === undefined) { + return { status: 404 as const, message: `Active renewal request not found for contract ${body.renewalRequestContractId}` } + } + const ctx = (choiceCtx as { disclosedContracts?: Array<{ templateId: string; contractId: string; createdEventBlob: string; synchronizerId: string }> }) + const disclosed = ctx.disclosedContracts ?? [] + + const metaMap: Record = { + AmuletRules: 'amulet-rules', + OpenMiningRound: 'open-round' + } + const extraArgsValues: Record = {} + for (const dc of disclosed) { + const parts = dc.templateId.split(':') + const entityName = parts[parts.length - 1] ?? '' + const key = metaMap[entityName] + if (key !== undefined) { + extraArgsValues[key] = { tag: 'AV_ContractId', value: cidOf(dc.contractId) } + } + } + + const disclosedForLedger = disclosed.map(dc => ({ + contractId: dc.contractId, + createdEventBlob: dc.createdEventBlob, + synchronizerId: dc.synchronizerId, + templateId: dc.templateId + })) + + const submitCtx = submitContextFromSession(this.cfg, this.cfg.appProviderParty, commandId) + const result = await exerciseChoice( + this.ledger, submitCtx, + LicenseRenewalRequest, LicenseRenewalRequest.LicenseRenewalRequest_CompleteRenewal, + body.renewalRequestContractId, + { + allocationCid: cidOf(body.allocationContractId), + licenseCid: cidOf(contractId), + extraArgs: { + context: { values: extraArgsValues }, + meta: { values: {} } + } + }, + disclosedForLedger + ) + const newLicenseId = findCreatedContract(result, ':Licensing.License:License') + return { status: 200 as const, body: { licenseId: newLicenseId ?? undefined } } + } + + async expireLicense(contractId: string, commandId: string | undefined, sessionParty: string, body: { meta?: { data?: Record } }) { + const license = await findLicenseById(this.pool, contractId) + if (license === undefined) return { status: 404 as const, message: `License not found for contract ${contractId}` } + const metaData: Record = { ...(body.meta?.data ?? {}) } + if (sessionParty !== this.cfg.appProviderParty) { + metaData['Note'] = 'Triggered by user request' + } + const ctx = submitContextFromSession(this.cfg, this.cfg.appProviderParty, commandId) + await exerciseChoice( + this.ledger, ctx, + License, License.License_Expire, + contractId, + { + actor: this.cfg.appProviderParty, + meta: { values: metaData } + } + ) + return { status: 200 as const, body: 'License expired successfully' } + } + + async withdrawLicenseRenewalRequest(contractId: string, commandId: string | undefined) { + const allocationReq = await findActiveAllocationRequestById(this.pool, contractId) + if (allocationReq === undefined) return { status: 404 as const, message: `AllocationRequest ${contractId} not found` } + const ctx = submitContextFromSession(this.cfg, this.cfg.appProviderParty, commandId) + await exerciseChoice( + this.ledger, ctx, + AllocationRequest, AllocationRequest.AllocationRequest_Withdraw, + contractId, + { extraArgs: { context: { values: {} }, meta: { values: {} } } } + ) + return { status: 204 as const } + } +} + +// `submit-and-wait-for-transaction` returns events in ACS_DELTA shape (CreatedEvent / +// ArchivedEvent only) and does NOT include the choice's exerciseResult. To find a contract +// the choice just created, walk the transaction's CreatedEvents and match on a templateId +// suffix (e.g. ':Licensing.License:License'). +type CantonTxResponse = { + transaction?: { + events?: Array<{ CreatedEvent?: { templateId?: string; contractId?: string } }> + } +} +const findCreatedContract = (response: unknown, templateIdSuffix: string): string | null => { + const events = (response as CantonTxResponse).transaction?.events ?? [] + for (const event of events) { + const ce = event.CreatedEvent + if (ce === undefined) continue + if (typeof ce.templateId !== 'string' || !ce.templateId.endsWith(templateIdSuffix)) continue + if (typeof ce.contractId !== 'string') continue + return ce.contractId + } + return null +} + +// Daml `Time` is microsecond-precision. Date.toISOString() emits only milliseconds, which +// round-trips through Canton as `xxx000 µs` and fails to match the wallet's allocation +// settlement on `LicenseRenewalRequest_CompleteRenewal` (exact-equality comparison). +// Pad with sub-millisecond noise from process.hrtime to emit 6-decimal precision; the wallet +// copies the timestamp verbatim, so only the format width matters — the lower 3 digits don't +// need to track wall-clock microseconds. +const toIsoMicros = (ms: number): string => { + const subMsNs = Number(process.hrtime.bigint() % 1_000_000n) + const us = Math.floor(subMsNs / 1000) + const iso = new Date(ms).toISOString() + return iso.replace(/\.(\d{3})Z$/, `.$1${String(us).padStart(3, '0')}Z`) +} + +// Mirrors java.time.Duration.parse semantics: only days, hours, minutes, seconds are +// supported. Months and years are rejected because their length is not fixed. +const parseDurationMs = (iso: string): number => { + const m = iso.match(/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/) + if (m === null) throw new Error(`Invalid ISO-8601 duration (only days/hours/minutes/seconds supported): ${iso}`) + const days = Number(m[1] ?? 0) + const hours = Number(m[2] ?? 0) + const minutes = Number(m[3] ?? 0) + const seconds = Number(m[4] ?? 0) + return ( + days * 24 * 3600_000 + + hours * 3600_000 + + minutes * 60_000 + + Math.round(seconds * 1000) + ) +} diff --git a/quickstart/backend-js/src/http/app.ts b/quickstart/backend-js/src/http/app.ts new file mode 100644 index 00000000..ba866c0d --- /dev/null +++ b/quickstart/backend-js/src/http/app.ts @@ -0,0 +1,76 @@ +import Fastify, { type FastifyInstance } from 'fastify' +import formbody from '@fastify/formbody' +import type pg from 'pg' +import type { BackendConfig } from '../config.js' +import { registerSession } from '../auth/session.js' +import { registerCsrf } from '../auth/csrf.js' +import { registerOAuth2 } from '../auth/oauth2.js' +import { registerSharedSecret, registerLogout } from '../auth/shared-secret.js' +import type { OAuth2Registry } from '../auth/oauth2-registry.js' +import type { TenantRepository } from '../tenants/repository.js' +import type { LedgerApi } from '../canton/ledger.js' +import type { TokenStandardClient } from '../token-standard/client.js' +import type { LicensingService } from '../domain/licensing/service.js' +import { registerAdmin } from '../routes/admin.js' +import { registerLoginLinks } from '../routes/login-links.js' +import { registerUser } from '../routes/user.js' +import { registerFeatureFlags } from '../routes/feature-flags.js' +import { registerAppInstallRequests } from '../routes/app-install-requests.js' +import { registerAppInstalls } from '../routes/app-installs.js' +import { registerLicenses } from '../routes/licenses.js' +import { registerLicenseRenewalRequests } from '../routes/license-renewal-requests.js' + +export interface Services { + cfg: BackendConfig + pool: pg.Pool + ledger: LedgerApi + tokenStandard: TokenStandardClient + tenants: TenantRepository + oauth2Registry: OAuth2Registry + licensing: LicensingService +} + +export const buildApp = async (services: Services): Promise => { + const { cfg, tenants, oauth2Registry } = services + const app = Fastify({ logger: { level: 'info' }, trustProxy: true }) + // Override Fastify's default JSON parser to accept empty bodies. The frontend's logout + // sends `Content-Type: application/json` with no body; the default parser rejects with + // "Body cannot be empty when content-type is set to 'application/json'". + app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => { + if (typeof body !== 'string' || body.length === 0) { done(null, undefined); return } + try { + done(null, JSON.parse(body)) + } catch (err) { + const e = err as Error & { statusCode?: number } + e.statusCode = 400 + done(e) + } + }) + await app.register(formbody) + await registerSession(app) + if (cfg.authMode === 'oauth2') await registerCsrf(app) + await registerOAuth2(app, cfg, oauth2Registry, tenants) + await registerSharedSecret(app, cfg, tenants) + await registerLogout(app) + + await registerAdmin(app, cfg, tenants, oauth2Registry) + await registerLoginLinks(app, cfg, oauth2Registry) + await registerUser(app, tenants) + await registerFeatureFlags(app, cfg) + await registerAppInstallRequests(app, services) + await registerAppInstalls(app, services) + await registerLicenses(app, services) + await registerLicenseRenewalRequests(app, services) + + app.get('/livez', async () => ({ status: 'ok' })) + app.get('/health', async () => ({ status: 'ok', backend: 'js', authMode: cfg.authMode })) + + app.setErrorHandler((err: unknown, req, reply) => { + const e = err as { statusCode?: number; message?: string } + const status = e.statusCode ?? 500 + if (status >= 500) req.log.error({ err }, 'unhandled error') + reply.code(status).send({ message: e.message ?? 'Unknown error' }) + }) + + return app +} diff --git a/quickstart/backend-js/src/pqs/contracts.ts b/quickstart/backend-js/src/pqs/contracts.ts new file mode 100644 index 00000000..4e8b3c7f --- /dev/null +++ b/quickstart/backend-js/src/pqs/contracts.ts @@ -0,0 +1,35 @@ +import type pg from 'pg' + +export interface ActiveContract

> { + contractId: string + payload: P +} + +// active($1) is the PQS-provided PostgreSQL function returning active (non-archived) contracts. +// Column names confirmed from Pqs.java: contract_id, payload. + +export const findActive = async

>( + pool: pg.Pool, + templateId: string +): Promise[]> => { + const res = await pool.query<{ contract_id: string; payload: P }>( + 'SELECT contract_id, payload FROM active($1)', + [templateId] + ) + return res.rows.map((r) => ({ contractId: r.contract_id, payload: r.payload })) +} + +export const findActiveByContractId = async

>( + pool: pg.Pool, + templateId: string, + contractId: string +): Promise | undefined> => { + const res = await pool.query<{ contract_id: string; payload: P }>( + 'SELECT contract_id, payload FROM active($1) WHERE contract_id = $2', + [templateId, contractId] + ) + const row = res.rows[0] + if (row === undefined) return undefined + return { contractId: row.contract_id, payload: row.payload } +} + diff --git a/quickstart/backend-js/src/pqs/db.ts b/quickstart/backend-js/src/pqs/db.ts new file mode 100644 index 00000000..51357b1d --- /dev/null +++ b/quickstart/backend-js/src/pqs/db.ts @@ -0,0 +1,11 @@ +import pg from 'pg' +import type { BackendConfig } from '../config.js' + +export const buildPool = (cfg: BackendConfig): pg.Pool => new pg.Pool({ + host: cfg.postgres.host, + port: cfg.postgres.port, + database: cfg.postgres.database, + user: cfg.postgres.user, + password: cfg.postgres.password, + max: 10 +}) diff --git a/quickstart/backend-js/src/routes/admin.ts b/quickstart/backend-js/src/routes/admin.ts new file mode 100644 index 00000000..5eae8f16 --- /dev/null +++ b/quickstart/backend-js/src/routes/admin.ts @@ -0,0 +1,110 @@ +import type { FastifyInstance } from 'fastify' +import type { BackendConfig } from '../config.js' +import type { TenantRepository } from '../tenants/repository.js' +import type { OAuth2Registry } from '../auth/oauth2-registry.js' +import { checkAdmin } from '../auth/admin-gate.js' + +interface TenantRegistrationRequest { + tenantId?: string + partyId?: string + walletUrl?: string + clientId?: string + issuerUrl?: string + internal?: boolean + users?: string[] +} + +export const registerAdmin = async (app: FastifyInstance, cfg: BackendConfig, repo: TenantRepository, oauth2Registry: OAuth2Registry): Promise => { + + app.get('/admin/tenant-registrations', async (req, reply) => { + if (!await checkAdmin(cfg, req, reply)) return + + if (cfg.authMode === 'oauth2') { + return oauth2Registry.list().map((entry) => { + const tenant = repo.get(entry.tenantId) + return { + tenantId: entry.tenantId, + partyId: tenant?.partyId ?? '', + walletUrl: tenant?.walletUrl ?? '', + clientId: entry.clientId, + issuerUrl: entry.issuerUrl, + internal: tenant?.internal ?? false + } + }) + } + return repo.list().map((t) => ({ + tenantId: t.tenantId, + partyId: t.partyId, + walletUrl: t.walletUrl ?? '', + internal: t.internal, + users: t.users + })) + }) + + app.post<{ Body: TenantRegistrationRequest }>('/admin/tenant-registrations', async (req, reply) => { + if (!await checkAdmin(cfg, req, reply)) return + + const body = req.body + if (!body.tenantId || !body.partyId) { + reply.code(400); return { message: 'tenantId and partyId are required' } + } + if (cfg.authMode === 'oauth2') { + if (!body.clientId || !body.issuerUrl) { + reply.code(400); return { message: 'clientId and issuerUrl are required in OAuth2 mode' } + } + const duplicate = oauth2Registry.list().some( + (e) => e.clientId === body.clientId && e.issuerUrl === body.issuerUrl + ) + if (duplicate) { + reply.code(409); return { message: 'ClientId-IssuerUrl combination already exists' } + } + } else { + if (!body.users || body.users.length === 0) { + reply.code(400); return { message: 'At least one user is required in shared-secret mode' } + } + } + if (repo.has(body.tenantId)) { + reply.code(409); return { message: 'TenantId already exists' } + } + + if (cfg.authMode === 'oauth2' && body.clientId && body.issuerUrl) { + await oauth2Registry.register(body.tenantId, body.clientId, body.issuerUrl) + } + repo.upsert({ + tenantId: body.tenantId, + partyId: body.partyId, + walletUrl: body.walletUrl ?? '', + clientId: body.clientId ?? '', + issuerUrl: body.issuerUrl ?? '', + internal: body.internal ?? false, + users: body.users + }) + reply.code(201) + return { + tenantId: body.tenantId, + partyId: body.partyId, + walletUrl: body.walletUrl ?? '', + clientId: body.clientId, + issuerUrl: body.issuerUrl, + internal: body.internal ?? false, + users: body.users + } + }) + + app.delete<{ Params: { tenantId: string } }>('/admin/tenant-registrations/:tenantId', async (req, reply) => { + if (!await checkAdmin(cfg, req, reply)) return + + if (!repo.has(req.params.tenantId)) { + reply.code(404); return { message: 'Not found' } + } + try { + if (cfg.authMode === 'oauth2') { + oauth2Registry.removeByTenantId(req.params.tenantId) + } + repo.delete(req.params.tenantId) + } catch (err) { + reply.code(500); return { message: (err as Error).message } + } + reply.code(204).send() + }) +} diff --git a/quickstart/backend-js/src/routes/app-install-requests.ts b/quickstart/backend-js/src/routes/app-install-requests.ts new file mode 100644 index 00000000..0b268e12 --- /dev/null +++ b/quickstart/backend-js/src/routes/app-install-requests.ts @@ -0,0 +1,42 @@ +import type { FastifyInstance } from 'fastify' +import type { Services } from '../http/app.js' +import { checkAdmin } from '../auth/admin-gate.js' + +export const registerAppInstallRequests = async (app: FastifyInstance, services: Services): Promise => { + const { cfg, licensing } = services + + app.get('/app-install-requests', async (req, reply) => { + if (req.session.user === undefined) { reply.code(401).send({ message: 'unauthorized' }); return } + return licensing.listAppInstallRequests(req.session.user.partyId) + }) + + // find-my-way (Fastify v5's router) does not support a literal ':' in route paths, + // so the OpenAPI ':accept' / ':reject' suffixes can't be expressed as separate route + // entries. We register one wildcard route per resource and dispatch on the suffix. + app.post<{ Params: { '*': string }; Querystring: { commandId?: string }; Body: unknown }>( + '/app-install-requests/*', + async (req, reply) => { + if (!await checkAdmin(cfg, req, reply)) return + const m = req.params['*'].match(/^(.+):(accept|reject)$/) + const contractId = m?.[1] + const action = m?.[2] + if (contractId === undefined || action === undefined) { + reply.code(404).send({ message: 'unknown_action' }) + return + } + + if (action === 'accept') { + const body = req.body as { installMeta?: { data?: Record }; meta?: { data?: Record } } + const res = await licensing.acceptAppInstallRequest(contractId, req.query.commandId, body) + if (res.status === 404) { reply.code(404).send({ message: res.message }); return } + reply.code(201).send(res.body) + return + } + + const body = req.body as { meta?: { data?: Record } } + const res = await licensing.rejectAppInstallRequest(contractId, req.query.commandId, body) + if (res.status === 404) { reply.code(404).send({ message: res.message }); return } + reply.code(204).send() + } + ) +} diff --git a/quickstart/backend-js/src/routes/app-installs.ts b/quickstart/backend-js/src/routes/app-installs.ts new file mode 100644 index 00000000..06180cbe --- /dev/null +++ b/quickstart/backend-js/src/routes/app-installs.ts @@ -0,0 +1,43 @@ +import type { FastifyInstance } from 'fastify' +import type { Services } from '../http/app.js' +import { checkAdmin } from '../auth/admin-gate.js' + +export const registerAppInstalls = async (app: FastifyInstance, services: Services): Promise => { + const { cfg, licensing } = services + + app.get('/app-installs', async (req, reply) => { + if (req.session.user === undefined) { reply.code(401).send({ message: 'unauthorized' }); return } + return licensing.listAppInstalls(req.session.user.partyId) + }) + + // See app-install-requests.ts for the rationale behind the wildcard + suffix dispatch. + app.post<{ Params: { '*': string }; Querystring: { commandId?: string }; Body: unknown }>( + '/app-installs/*', + async (req, reply) => { + if (req.session.user === undefined) { reply.code(401).send({ message: 'unauthorized' }); return } + const m = req.params['*'].match(/^(.+):(create-license|cancel)$/) + const contractId = m?.[1] + const action = m?.[2] + if (contractId === undefined || action === undefined) { + reply.code(404).send({ message: 'unknown_action' }) + return + } + + if (action === 'create-license') { + if (!await checkAdmin(cfg, req, reply)) return + const body = req.body as { params?: { meta?: { data?: Record } } } + const res = await licensing.createLicense(contractId, req.query.commandId, body) + if (res.status === 404) { reply.code(404).send({ message: res.message }); return } + if (res.status === 403) { reply.code(403).send({ message: res.message }); return } + reply.code(201).send(res.body) + return + } + + const body = req.body as { meta?: { data?: Record } } + const res = await licensing.cancelAppInstall(contractId, req.query.commandId, req.session.user.partyId, body) + if (res.status === 404) { reply.code(404).send({ message: res.message }); return } + if (res.status === 403) { reply.code(403).send({ message: res.message }); return } + reply.code(204).send() + } + ) +} diff --git a/quickstart/backend-js/src/routes/feature-flags.ts b/quickstart/backend-js/src/routes/feature-flags.ts new file mode 100644 index 00000000..27aa4c85 --- /dev/null +++ b/quickstart/backend-js/src/routes/feature-flags.ts @@ -0,0 +1,8 @@ +import type { FastifyInstance } from 'fastify' +import type { BackendConfig } from '../config.js' + +export const registerFeatureFlags = async (app: FastifyInstance, cfg: BackendConfig): Promise => { + app.get('/feature-flags', async () => ({ + authMode: cfg.authMode + })) +} diff --git a/quickstart/backend-js/src/routes/license-renewal-requests.ts b/quickstart/backend-js/src/routes/license-renewal-requests.ts new file mode 100644 index 00000000..3cd182d4 --- /dev/null +++ b/quickstart/backend-js/src/routes/license-renewal-requests.ts @@ -0,0 +1,26 @@ +import type { FastifyInstance } from 'fastify' +import type { Services } from '../http/app.js' +import { checkAdmin } from '../auth/admin-gate.js' + +export const registerLicenseRenewalRequests = async (app: FastifyInstance, services: Services): Promise => { + const { cfg, licensing } = services + + // See app-install-requests.ts for the rationale behind the wildcard + suffix dispatch. + // Even with a single action, the literal-colon limitation in find-my-way prevents using + // ':contractId\\:withdraw' directly — the param name gets mangled to 'contractId:withdraw'. + app.post<{ Params: { '*': string }; Querystring: { commandId?: string } }>( + '/license-renewal-requests/*', + async (req, reply) => { + if (!await checkAdmin(cfg, req, reply)) return + const m = req.params['*'].match(/^(.+):(withdraw)$/) + const contractId = m?.[1] + if (contractId === undefined) { + reply.code(404).send({ message: 'unknown_action' }) + return + } + const res = await licensing.withdrawLicenseRenewalRequest(contractId, req.query.commandId) + if (res.status === 404) { reply.code(404).send({ message: res.message }); return } + reply.code(204).send() + } + ) +} diff --git a/quickstart/backend-js/src/routes/licenses.ts b/quickstart/backend-js/src/routes/licenses.ts new file mode 100644 index 00000000..469d0c4b --- /dev/null +++ b/quickstart/backend-js/src/routes/licenses.ts @@ -0,0 +1,55 @@ +import type { FastifyInstance } from 'fastify' +import type { Services } from '../http/app.js' +import { checkAdmin } from '../auth/admin-gate.js' + +export const registerLicenses = async (app: FastifyInstance, services: Services): Promise => { + const { cfg, licensing } = services + + app.get('/licenses', async (req, reply) => { + if (req.session.user === undefined) { reply.code(401).send({ message: 'unauthorized' }); return } + return licensing.listLicenses(req.session.user.partyId) + }) + + // See app-install-requests.ts for the rationale behind the wildcard + suffix dispatch. + app.post<{ Params: { '*': string }; Querystring: { commandId?: string }; Body: unknown }>( + '/licenses/*', + async (req, reply) => { + if (req.session.user === undefined) { reply.code(401).send({ message: 'unauthorized' }); return } + const m = req.params['*'].match(/^(.+):(renew|complete-renewal|expire)$/) + const contractId = m?.[1] + const action = m?.[2] + if (contractId === undefined || action === undefined) { + reply.code(404).send({ message: 'unknown_action' }) + return + } + + if (action === 'renew') { + if (!await checkAdmin(cfg, req, reply)) return + const body = req.body as { + licenseFeeCc: number + licenseExtensionDuration: string + prepareUntilDuration: string + settleBeforeDuration: string + description: string + } + const res = await licensing.renewLicense(contractId, req.query.commandId, body) + if (res.status === 404) { reply.code(404).send({ message: res.message }); return } + reply.code(201).send() + return + } + + if (action === 'complete-renewal') { + if (!await checkAdmin(cfg, req, reply)) return + const body = req.body as { renewalRequestContractId: string; allocationContractId: string } + const res = await licensing.completeLicenseRenewal(contractId, req.query.commandId, body) + if (res.status === 404) { reply.code(404).send({ message: res.message }); return } + return res.body + } + + const body = req.body as { meta?: { data?: Record } } + const res = await licensing.expireLicense(contractId, req.query.commandId, req.session.user.partyId, body) + if (res.status === 404) { reply.code(404).send({ message: res.message }); return } + return res.body + } + ) +} diff --git a/quickstart/backend-js/src/routes/login-links.ts b/quickstart/backend-js/src/routes/login-links.ts new file mode 100644 index 00000000..fbaedca4 --- /dev/null +++ b/quickstart/backend-js/src/routes/login-links.ts @@ -0,0 +1,13 @@ +import type { FastifyInstance } from 'fastify' +import type { BackendConfig } from '../config.js' +import type { OAuth2Registry } from '../auth/oauth2-registry.js' + +export const registerLoginLinks = async (app: FastifyInstance, cfg: BackendConfig, oauth2Registry: OAuth2Registry): Promise => { + app.get('/login-links', async () => { + if (cfg.authMode !== 'oauth2') return [] + return oauth2Registry.list().map((entry) => ({ + name: entry.tenantId, + url: oauth2Registry.loginUrl(entry.registrationId) + })) + }) +} diff --git a/quickstart/backend-js/src/routes/user.ts b/quickstart/backend-js/src/routes/user.ts new file mode 100644 index 00000000..fa81fbbc --- /dev/null +++ b/quickstart/backend-js/src/routes/user.ts @@ -0,0 +1,16 @@ +import type { FastifyInstance } from 'fastify' +import type { TenantRepository } from '../tenants/repository.js' + +export const registerUser = async (app: FastifyInstance, tenants: TenantRepository): Promise => { + app.get('/user', async (req, reply) => { + const u = req.session.user + if (u === undefined) { reply.code(401).send({ message: 'unauthorized' }); return } + const tenant = tenants.get(u.tenantId) + const party = u.partyId !== '' ? u.partyId : tenant?.partyId ?? '' + const walletUrl = tenant?.walletUrl ?? '' + const roles = u.roles ?? (u.isAdmin === true ? ['ROLE_ADMIN'] : ['ROLE_USER']) + const isAdmin = u.isAdmin ?? roles.includes('ROLE_ADMIN') + + return { name: u.name, party, roles, isAdmin, walletUrl } + }) +} diff --git a/quickstart/backend-js/src/server.ts b/quickstart/backend-js/src/server.ts new file mode 100644 index 00000000..e0bb9cca --- /dev/null +++ b/quickstart/backend-js/src/server.ts @@ -0,0 +1,30 @@ +import { loadConfig } from './config.js' +import { buildApp, type Services } from './http/app.js' +import { buildPool } from './pqs/db.js' +import { CantonTokenProvider } from './canton/auth.js' +import { LedgerApi } from './canton/ledger.js' +import { TokenStandardClient } from './token-standard/client.js' +import { TenantRepository, seedAppProvider } from './tenants/repository.js' +import { initOAuth2Registry } from './auth/oauth2.js' +import { LicensingService } from './domain/licensing/service.js' + +const main = async (): Promise => { + const cfg = loadConfig() + const pool = buildPool(cfg) + const tokens = new CantonTokenProvider(cfg) + const ledger = new LedgerApi(cfg, tokens) + const tokenStandard = new TokenStandardClient(cfg) + const tenants = new TenantRepository() + seedAppProvider(tenants, cfg) + const oauth2Registry = await initOAuth2Registry(cfg) + const licensing = new LicensingService(cfg, pool, ledger, tokenStandard, tenants) + const services: Services = { cfg, pool, ledger, tokenStandard, tenants, oauth2Registry, licensing } + const app = await buildApp(services) + await app.listen({ port: cfg.port, host: '0.0.0.0' }) + app.log.info({ backend: 'js', port: cfg.port, authMode: cfg.authMode }, 'backend-js listening') +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/quickstart/backend-js/src/tenants/repository.ts b/quickstart/backend-js/src/tenants/repository.ts new file mode 100644 index 00000000..37aa9076 --- /dev/null +++ b/quickstart/backend-js/src/tenants/repository.ts @@ -0,0 +1,42 @@ +import type { BackendConfig } from '../config.js' + +export interface Tenant { + tenantId: string + partyId: string + walletUrl: string + clientId: string + issuerUrl: string + internal: boolean + users?: string[] +} + +export class TenantRepository { + private readonly tenants = new Map() + + list(): Tenant[] { return [...this.tenants.values()] } + listExternal(): Tenant[] { return this.list().filter((t) => !t.internal) } + get(tenantId: string): Tenant | undefined { return this.tenants.get(tenantId) } + has(tenantId: string): boolean { return this.tenants.has(tenantId) } + put(tenant: Tenant): void { + if (this.tenants.has(tenant.tenantId)) throw new Error(`Duplicate tenantId: ${tenant.tenantId}`) + this.tenants.set(tenant.tenantId, tenant) + } + upsert(tenant: Tenant): void { this.tenants.set(tenant.tenantId, tenant) } + delete(tenantId: string): void { + if (!this.tenants.delete(tenantId)) throw new Error(`No tenant: ${tenantId}`) + } +} + +export const seedAppProvider = (repo: TenantRepository, cfg: BackendConfig): void => { + repo.put({ + tenantId: 'AppProvider', + partyId: cfg.appProviderParty, + walletUrl: '', + clientId: cfg.oauth2?.backendOidcClientId ?? '', + issuerUrl: cfg.oauth2?.issuerUrl ?? '', + internal: true, + // Mirrors Java's application-shared-secret.yml `application.tenants.AppProvider.users`. + // Matches AUTH_APP_PROVIDER_WALLET_ADMIN_USER_NAME from docker/modules/localnet/env/app-provider-auth-on.env. + users: cfg.authMode === 'shared-secret' ? ['app-provider'] : undefined + }) +} diff --git a/quickstart/backend-js/src/token-standard/client.ts b/quickstart/backend-js/src/token-standard/client.ts new file mode 100644 index 00000000..f50e6873 --- /dev/null +++ b/quickstart/backend-js/src/token-standard/client.ts @@ -0,0 +1,32 @@ +import type { BackendConfig } from '../config.js' + +export class TokenStandardClient { + constructor(private readonly cfg: BackendConfig) {} + + private async get(path: string): Promise { + const res = await fetch(`${this.cfg.registryBaseUri}${path}`) + if (!res.ok) throw new Error(`${path} ${res.status}: ${await res.text()}`) + return await res.json() as T + } + + private async post(path: string, body: unknown): Promise { + const res = await fetch(`${this.cfg.registryBaseUri}${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body) + }) + if (!res.ok) throw new Error(`${path} ${res.status}: ${await res.text()}`) + return await res.json() as T + } + + async getRegistryAdminId(): Promise<{ adminId: string }> { + return this.get<{ adminId: string }>('/registry/metadata/v1/info') + } + + async getAllocationTransferContext(allocationCid: string): Promise { + return this.post( + `/registry/allocations/v1/${encodeURIComponent(allocationCid)}/choice-contexts/execute-transfer`, + {} + ) + } +} diff --git a/quickstart/backend-js/tsconfig.json b/quickstart/backend-js/tsconfig.json new file mode 100644 index 00000000..aa27527e --- /dev/null +++ b/quickstart/backend-js/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*.ts"] +} diff --git a/quickstart/buildSrc/src/main/kotlin/ConfigureProfilesTask.kt b/quickstart/buildSrc/src/main/kotlin/ConfigureProfilesTask.kt index f7ea19df..e1a2468e 100644 --- a/quickstart/buildSrc/src/main/kotlin/ConfigureProfilesTask.kt +++ b/quickstart/buildSrc/src/main/kotlin/ConfigureProfilesTask.kt @@ -7,7 +7,7 @@ import java.io.File open class ConfigureProfilesTask : DefaultTask() { - enum class OptionType { BOOLEAN, PARTY_HINT, AUTH_MODE, TEST_MODE } + enum class OptionType { BOOLEAN, PARTY_HINT, AUTH_MODE, TEST_MODE, BACKEND } data class Option( val promptText: String, @@ -32,6 +32,7 @@ open class ConfigureProfilesTask : DefaultTask() { OptionType.PARTY_HINT ), Option("Enable TEST_MODE", "TEST_MODE", OptionType.TEST_MODE), + Option("Use Node.js backend (instead of Java)", "BACKEND", OptionType.BACKEND), ) options.forEach { option -> @@ -72,10 +73,20 @@ open class ConfigureProfilesTask : DefaultTask() { CAUTION: Not intended for use in production environments. Activates the test profile in the backend service. When enabled, party ID resolution is derived from the JWT token's party_id claim, overriding the tenant registration's party ID. - This feature is designed for testing purposes to generate a unique AppUser party for each test run and ensure isolation. + This feature is designed for testing purposes to generate a unique AppUser party for each test run and ensure isolation. """.trimIndent() ) } + + OptionType.BACKEND -> { + val boolValue = promptForBoolean(option.promptText, default = false) + option.value = if (boolValue) { + "js" + } else { + "java" + } + println(" ${option.envVarName} set to '${option.value}'.\n") + } } System.out.flush() } diff --git a/quickstart/daml/build.gradle.kts b/quickstart/daml/build.gradle.kts index 574dd6ff..c565c647 100644 --- a/quickstart/daml/build.gradle.kts +++ b/quickstart/daml/build.gradle.kts @@ -33,6 +33,23 @@ tasks.register(" dependsOn("compileDaml") } +tasks.register("tsCodegen") { + val requiredVersion = VersionFiles.damlYamlSdk + val dar = file("$projectDir/licensing/.daml/dist/quickstart-licensing-0.0.1.dar") + val outputDir = file("$rootDir/backend-js/generated") + + inputs.file(dar) + outputs.dir(outputDir) + + doFirst { + outputDir.deleteRecursively() + outputDir.mkdirs() + } + commandLine("dpm", "codegen-js", dar.absolutePath, "-o", outputDir.absolutePath) + environment("DPM_SDK_VERSION", requiredVersion) + dependsOn("compileDaml") +} + tasks.named("build") { - dependsOn("codeGen") + dependsOn("codeGen", "tsCodegen") } diff --git a/quickstart/docker/backend-js/Dockerfile b/quickstart/docker/backend-js/Dockerfile new file mode 100644 index 00000000..c0226aa1 --- /dev/null +++ b/quickstart/docker/backend-js/Dockerfile @@ -0,0 +1,23 @@ +# syntax=docker/dockerfile:1.7 +FROM node:22-alpine AS build +WORKDIR /app +COPY backend-js/package.json backend-js/package-lock.json ./ +COPY backend-js/generated/ ./generated/ +RUN npm ci +# Vendored token-standard specs live under backend/. Copy them into a stable in-image path +# so prebuild can find them; the host script uses the same relative path via a symlink. +COPY backend/src/main/resources/vendored/ ./vendored/ +COPY backend-js/ ./ +RUN VENDORED_DIR=./vendored npm run build + +FROM node:22-alpine AS runtime +WORKDIR /app +RUN apk add --no-cache wget +COPY --from=build /app/package.json /app/package-lock.json ./ +COPY --from=build /app/generated/ ./generated/ +RUN npm ci --omit=dev +COPY --from=build /app/dist ./dist +COPY docker/backend-js/start.sh /app/start.sh +RUN chmod +x /app/start.sh +EXPOSE 8080 +CMD ["/app/start.sh"] diff --git a/quickstart/docker/backend-js/compose.yaml b/quickstart/docker/backend-js/compose.yaml new file mode 100644 index 00000000..5c80fed5 --- /dev/null +++ b/quickstart/docker/backend-js/compose.yaml @@ -0,0 +1,35 @@ +services: + backend-service: + image: !reset null + build: + context: . + dockerfile: docker/backend-js/Dockerfile + container_name: backend-service + labels: + - "description=Node.js backend service for the Quickstart Licensing workflow. + APP_PROVIDER_PARTY is resolved at runtime via the onboarding volume." + working_dir: /app + env_file: + - ./docker/backend-service/env/app.env + - ./docker/backend-service/onboarding/env/${AUTH_MODE}.env + environment: + NODE_ENV: production + BACKEND: js + # The JS backend talks to Canton over the JSON Ledger API, not gRPC. + LEDGER_PORT: "3${PARTICIPANT_JSON_API_PORT_SUFFIX}" + volumes: !override + - onboarding:/onboarding + command: /app/start.sh + ports: + - "${BACKEND_PORT}:${BACKEND_PORT}" + depends_on: + pqs-app-provider: + condition: service_started + splice-onboarding: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:${BACKEND_PORT}/livez"] + interval: 5s + timeout: 3s + retries: 30 + start_period: 10s diff --git a/quickstart/docker/backend-js/start.sh b/quickstart/docker/backend-js/start.sh new file mode 100755 index 00000000..6074c405 --- /dev/null +++ b/quickstart/docker/backend-js/start.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu + +# Mirror Java's start.sh: source every onboarding script so APP_PROVIDER_PARTY etc. are exported. +for script in /onboarding/backend-service/on/*.sh; do + [ -f "$script" ] && . "$script" +done + +exec node /app/dist/server.js