diff --git a/launcher/scripts/widget-complexity.sh b/launcher/scripts/widget-complexity.sh new file mode 100755 index 00000000..fa6c7661 --- /dev/null +++ b/launcher/scripts/widget-complexity.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run from the platform root, independently of the caller's working directory. +root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +widget="${1:-}" + +if [[ ! "$widget" =~ ^[a-z][a-z0-9_-]*$ ]]; then + echo "Usage: mise run complexity -- " >&2 + echo "Widget must be a single name such as cmsblock or productgallery." >&2 + exit 2 +fi + +widget_dir="$root/widgets/$widget" +if [[ ! -d "$widget_dir" ]]; then + echo "Unknown widget '$widget': $widget_dir does not exist." >&2 + exit 2 +fi + +targets=() +for directory in src api; do + if [[ -d "$widget_dir/$directory" ]]; then + targets+=("$directory") + fi +done + +if [[ ${#targets[@]} -eq 0 ]]; then + echo "Widget '$widget' has neither src nor api to analyze." >&2 + exit 2 +fi + +if ! command -v lizard >/dev/null 2>&1; then + echo "Lizard not found. Run this check through mise to install the pinned tool." >&2 + exit 2 +fi + +cd "$widget_dir" +exec lizard "${targets[@]}" -C 10 -a 5 diff --git a/mise.toml b/mise.toml index 65ae3c56..3ebb9ebd 100644 --- a/mise.toml +++ b/mise.toml @@ -45,3 +45,8 @@ run = "./launcher/scripts/widgets-deploy.sh" [tasks."widgets-test"] description = "Test the ReactEdge deployed widgets" run = "./launcher/scripts/widgets-test.sh" + +[tasks.complexity] +description = "Check Lizard complexity for one widget: mise run complexity -- " +tools = {uv = "latest", "pipx:lizard" = "1.24.1"} +run = "./launcher/scripts/widget-complexity.sh {{arg(name='widget', required=true)}}" diff --git a/services/cmsblock/.env.sample b/services/cmsblock/.env.sample new file mode 100644 index 00000000..4f75091f --- /dev/null +++ b/services/cmsblock/.env.sample @@ -0,0 +1,4 @@ +PORT=4190 +FRONTEND_URL=http://localhost:3000 +OTEL_HOST=http://localhost:4318 +OTEL_SERVICE_NAME=reactedge-cmsblock diff --git a/services/cmsblock/.gitignore b/services/cmsblock/.gitignore new file mode 100644 index 00000000..e5980245 --- /dev/null +++ b/services/cmsblock/.gitignore @@ -0,0 +1,3 @@ +# CMSBlock drafts and environment secrets are runtime data. +data/ +.env diff --git a/services/cmsblock/README.md b/services/cmsblock/README.md new file mode 100644 index 00000000..3640b1b8 --- /dev/null +++ b/services/cmsblock/README.md @@ -0,0 +1,69 @@ +# CMSBlock — first iteration + +This service was scaffolded from Operational Intelligence's `create_server` +MCP template, like ReactEdge's ListingRecord service. It uses Express, +TypeScript, request/route OpenTelemetry spans and separate access, controller, +routes, and model layers. + +This **development-only** iteration supports a single block, `demo`, +with a deterministic generator (not an AI model). + +## Start locally + +```bash +npm --prefix services/cmsblock ci +npm --prefix services/cmsblock start +``` + +Default HTTP address: `http://127.0.0.1:4190`. + +For local development, start the CMSBlock widget separately: + +```bash +npm --prefix widgets/cmsblock dev +``` + +The widget uses `http://127.0.0.1:4190/cmsblock/blocks/demo` by default. +Set `VITE_CMSBLOCK_URL` to override the service origin. + +## API + +| Method | Path | Action | +| --- | --- | --- | +| GET | `/cmsblock/status` | Scaffold status | +| GET | `/cmsblock/blocks/demo` | Read saved block | +| PUT | `/cmsblock/blocks/demo` | Save source, style and layout | +| POST | `/cmsblock/blocks/demo/generate` | Generate a revision for review | +| POST | `/cmsblock/blocks/demo/approve` | Publish the pending revision | +| POST | `/cmsblock/blocks/demo/reject` | Discard the pending revision | + +Source content remains separate from pending and approved HTML/CSS. New +drafts do not change the approved revision until approval. The JSON store +uses `data/demo.json` by default and is compatible with the old prototype. +The generator supports three editorial styles and three image arrangements. + +Set `PORT`, `CMSBLOCK_DATA_DIR`, `FRONTEND_URL` (comma-separated CORS +origins), `OTEL_HOST`, and `OTEL_SERVICE_NAME` as needed. + +## Checks + +```bash +npm --prefix services/cmsblock run typecheck +npm --prefix services/cmsblock test +mise run complexity -- cmsblock +``` + +Run these checks locally when changing the service. To verify startup, call +`curl --fail http://127.0.0.1:4190/cmsblock/status` after starting it. + +## Limitations + +**Do not expose this API to the public internet.** It has no authentication, +authorization or rate limiting. It listens on loopback by default; CORS is +not an authentication mechanism. The deterministic generator converts +supplied HTML to escaped text and retains up to three HTTPS image URLs; +it does not preserve arbitrary HTML or interpret style reference images. +The widget uses sandboxed iframe previews. Published output will require +separate security review and sanitisation before it can be served directly +as SSR markup. Telemetry exporter delivery is not covered by the current +HTTP lifecycle test. diff --git a/services/cmsblock/package-lock.json b/services/cmsblock/package-lock.json new file mode 100644 index 00000000..0b617961 --- /dev/null +++ b/services/cmsblock/package-lock.json @@ -0,0 +1,1960 @@ +{ + "name": "@reactedge/cmsblock", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@reactedge/cmsblock", + "version": "0.1.0", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/exporter-trace-otlp-http": "^0.219.0", + "@opentelemetry/resources": "^2.8.0", + "@opentelemetry/sdk-trace-base": "^2.8.0", + "@opentelemetry/sdk-trace-node": "^2.8.0", + "cors": "^2.8.6", + "dotenv": "^17.4.2", + "express": "^5.2.1" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/node": "^26.0.0", + "tsx": "^4.22.4", + "typescript": "^6.0.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz", + "integrity": "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.11.0.tgz", + "integrity": "sha512-Tr79DyWI8itsBdg+jH+opjfrwLzX+erk1/ExkIwhWoAVjVrJIn2y5+cGjTC0Vy8fyNIA/y8wuJPZwr1T3xCZeQ==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.219.0.tgz", + "integrity": "sha512-9t6SvBXXBEjOBcIzgozvBbd3jWrv3Gt3ngGhl1fhdZ/zRc7oZDVOFEqbi2zlBpW9BXhgDMKv422J0DL/3iQWfw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/resources": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.219.0.tgz", + "integrity": "sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-transformer": "0.219.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.219.0.tgz", + "integrity": "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-logs": "0.219.0", + "@opentelemetry/sdk-metrics": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.11.0.tgz", + "integrity": "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.11.0.tgz", + "integrity": "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.219.0.tgz", + "integrity": "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs/node_modules/@opentelemetry/resources": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.8.0.tgz", + "integrity": "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.11.0.tgz", + "integrity": "sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.11.0.tgz", + "integrity": "sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.11.0", + "@opentelemetry/resources": "2.11.0", + "@opentelemetry/sdk-trace": "2.11.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/core": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.11.0.tgz", + "integrity": "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.11.0.tgz", + "integrity": "sha512-CuvCMJmZxswhNLlM2LfuLOW3h3fZujA4hsG4B+Sz4dX2zvaXO8Ng74cnDHWD64gLszTlhiG3c0iNUjj4g+0/sA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "2.11.0", + "@opentelemetry/core": "2.11.0", + "@opentelemetry/sdk-trace-base": "2.11.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/core": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.11.0.tgz", + "integrity": "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace/node_modules/@opentelemetry/core": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.11.0.tgz", + "integrity": "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", + "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/node/node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "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==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/services/cmsblock/package.json b/services/cmsblock/package.json new file mode 100644 index 00000000..754ec742 --- /dev/null +++ b/services/cmsblock/package.json @@ -0,0 +1,30 @@ +{ + "name": "@reactedge/cmsblock", + "version": "0.1.0", + "private": true, + "description": "CMSBlock Express service", + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "start": "node --import tsx src/index.ts", + "typecheck": "tsc --noEmit", + "test": "node --import tsx --test test/*.test.ts" + }, + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/exporter-trace-otlp-http": "^0.219.0", + "@opentelemetry/resources": "^2.8.0", + "@opentelemetry/sdk-trace-base": "^2.8.0", + "@opentelemetry/sdk-trace-node": "^2.8.0", + "cors": "^2.8.6", + "dotenv": "^17.4.2", + "express": "^5.2.1" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/node": "^26.0.0", + "tsx": "^4.22.4", + "typescript": "^6.0.3" + } +} diff --git a/services/cmsblock/src/access/index.ts b/services/cmsblock/src/access/index.ts new file mode 100644 index 00000000..fbfbd260 --- /dev/null +++ b/services/cmsblock/src/access/index.ts @@ -0,0 +1,6 @@ +import {setupJsonBodyParse} from "./jsonParser.js"; +import {Application} from "express"; + +export default (app: Application) => { + setupJsonBodyParse(app) +} diff --git a/services/cmsblock/src/access/jsonParser.ts b/services/cmsblock/src/access/jsonParser.ts new file mode 100644 index 00000000..a3d8bd3d --- /dev/null +++ b/services/cmsblock/src/access/jsonParser.ts @@ -0,0 +1,6 @@ +import type {Application} from 'express'; +import express from 'express'; + +export function setupJsonBodyParse(app: Application): void { + app.use(express.json({limit: '120kb'})); +} diff --git a/services/cmsblock/src/config.ts b/services/cmsblock/src/config.ts new file mode 100644 index 00000000..06232007 --- /dev/null +++ b/services/cmsblock/src/config.ts @@ -0,0 +1,17 @@ +import dotenv from 'dotenv'; +import {resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +dotenv.config({quiet: true}); +export const config = { + port: Number(process.env.PORT ?? '4190'), + frontendUrl: process.env.FRONTEND_URL ?? + 'http://localhost:5173,http://127.0.0.1:5173,http://localhost:3001,http://127.0.0.1:3001', + dataDirectory: resolve(process.env.CMSBLOCK_DATA_DIR ?? + fileURLToPath(new URL('../data/', import.meta.url))), + route: {servicePrefix: '/cmsblock'}, + observability: { + otelHost: process.env.OTEL_HOST ?? 'http://localhost:4318', + serviceName: process.env.OTEL_SERVICE_NAME ?? 'reactedge-cmsblock', + }, +}; diff --git a/services/cmsblock/src/controller/cmsblock-handler.ts b/services/cmsblock/src/controller/cmsblock-handler.ts new file mode 100644 index 00000000..3b88511d --- /dev/null +++ b/services/cmsblock/src/controller/cmsblock-handler.ts @@ -0,0 +1,46 @@ +import type {Request, Response} from 'express'; +import {CmsBlockValidationError, CmsBlockWorkflowError} from '../model/cmsblock/errors'; +import type {CmsBlockStore} from '../model/cmsblock/cmsblock-store'; +import type {Operation} from '../observability/operation'; + +export class CmsBlockHandler { + private store(res: Response): CmsBlockStore { + return res.app.locals.cmsblocks as CmsBlockStore; + } + + private async handle(res: Response, run: () => Promise): Promise { + const operation = res.locals.routeOperation as Operation; + try { + const result = await run(); + if (result === null) { + operation.complete(404); + res.status(404).json({error: 'Block not created yet.'}); + return; + } + operation.succeed(); + res.json(result); + } catch (error) { + operation.fail(error); + const invalid = error instanceof CmsBlockValidationError || + error instanceof CmsBlockWorkflowError; + res.status(invalid ? 400 : 500).json({ + error: error instanceof Error ? error.message : 'CMSBlock operation failed.', + }); + } + } + + get = (_req: Request, res: Response): Promise => + this.handle(res, () => this.store(res).get()); + + save = (req: Request, res: Response): Promise => + this.handle(res, () => this.store(res).save(req.body)); + + generate = (_req: Request, res: Response): Promise => + this.handle(res, () => this.store(res).generate()); + + approve = (_req: Request, res: Response): Promise => + this.handle(res, () => this.store(res).approve()); + + reject = (_req: Request, res: Response): Promise => + this.handle(res, () => this.store(res).reject()); +} diff --git a/services/cmsblock/src/controller/status-handler.ts b/services/cmsblock/src/controller/status-handler.ts new file mode 100644 index 00000000..5db484bf --- /dev/null +++ b/services/cmsblock/src/controller/status-handler.ts @@ -0,0 +1,18 @@ +import {Request, Response} from "express"; +import {Operation} from "../observability/operation"; + +export class StatusHandler { + status = async (_req: Request, res: Response): Promise => { + const operation = res.locals.routeOperation as Operation; + + try { + operation.succeed(); + res.json({status: 'ok'}); + } catch (error) { + operation.fail(error); + res.status(500).json({ + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + }; +} diff --git a/services/cmsblock/src/index.ts b/services/cmsblock/src/index.ts new file mode 100644 index 00000000..89f4e255 --- /dev/null +++ b/services/cmsblock/src/index.ts @@ -0,0 +1,3 @@ +import {startServer} from './lib/server'; + +void startServer(); diff --git a/services/cmsblock/src/lib/cors-setup.ts b/services/cmsblock/src/lib/cors-setup.ts new file mode 100644 index 00000000..83f06ebd --- /dev/null +++ b/services/cmsblock/src/lib/cors-setup.ts @@ -0,0 +1,8 @@ +import cors from 'cors'; +import {config} from '../config'; + +export function corsOptions() { + const allowed = config.frontendUrl.split(',').map(x => x.trim()).filter(Boolean); + const checker = cors({origin: allowed, methods: 'GET,HEAD,OPTIONS,PUT,POST', allowedHeaders: ['Content-Type']}); + return checker; +} diff --git a/services/cmsblock/src/lib/initilisers/index.ts b/services/cmsblock/src/lib/initilisers/index.ts new file mode 100644 index 00000000..cfbfcbe7 --- /dev/null +++ b/services/cmsblock/src/lib/initilisers/index.ts @@ -0,0 +1,15 @@ +import type {Application} from 'express'; +import access from '../../access/index'; +import routes from '../../routes/index'; +import {config} from '../../config'; +import {CmsBlockStore} from '../../model/cmsblock/cmsblock-store'; +import {setupTelemetry} from '../../observability/tracing'; +import {createRequestOperationMiddleware} from '../../observability/request-operation-middleware'; + +export async function initialiseApp(app: Application): Promise { + app.locals.cmsblocks = new CmsBlockStore(config.dataDirectory); + setupTelemetry(app); + app.use(createRequestOperationMiddleware('cmsblock.request')); + access(app); + routes(app); +} diff --git a/services/cmsblock/src/lib/server.ts b/services/cmsblock/src/lib/server.ts new file mode 100644 index 00000000..5b6d6433 --- /dev/null +++ b/services/cmsblock/src/lib/server.ts @@ -0,0 +1,35 @@ +import express, {Application} from 'express' +import {config} from "../config"; +import {initialiseApp} from "./initilisers"; +import {OpenTelemetryObserver} from "../observability/activity"; + +export const startServer = async () => { + const app: Application = express() + const port = config.port + + await initialiseApp(app) + const telemetry = app.locals.telemetry as OpenTelemetryObserver; + + try { + await new Promise((resolve, reject) => { + const server = app.listen(port, '127.0.0.1', (error?: Error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + server.once('error', reject); + }); + telemetry.logObservation('cmsblock.server.started', { + 'server.port': port + }); + } catch (error) { + const operation = telemetry.startOperation( + 'cmsblock.server.failed', + {'server.port': port} + ); + operation.fail(error); + throw error; + } +} diff --git a/services/cmsblock/src/lib/url.ts b/services/cmsblock/src/lib/url.ts new file mode 100644 index 00000000..0bfc11da --- /dev/null +++ b/services/cmsblock/src/lib/url.ts @@ -0,0 +1,26 @@ +import { URL } from 'url'; + +export const normalizeUrl = (rawUrl: string) => { + const url = new URL(rawUrl); + + // Normalize pathname by collapsing multiple slashes + url.pathname = url.pathname.replace(/\/{2,}/g, '/'); + + // Also clean search parameters and hash if needed (optional) + // url.search = decodeURIComponent(url.search); + // url.hash = decodeURIComponent(url.hash); + + return url.toString(); +} + +export const sanitiseUrl = (rawUrl: string) => { + const dummyHost = 'https://localurl.com' + const url = new URL(`${dummyHost}${rawUrl}`); + + // Obscure each query parameter's value + url.searchParams.forEach((value, key) => { + url.searchParams.set(key, '***'); + }); + + return `${url.pathname}${url.search}` +} \ No newline at end of file diff --git a/services/cmsblock/src/model/cmsblock/cmsblock-store.ts b/services/cmsblock/src/model/cmsblock/cmsblock-store.ts new file mode 100644 index 00000000..b571d9b8 --- /dev/null +++ b/services/cmsblock/src/model/cmsblock/cmsblock-store.ts @@ -0,0 +1,94 @@ +import {readFile, mkdir, rename, writeFile, rm} from 'node:fs/promises'; +import {join} from 'node:path'; +import {randomUUID} from 'node:crypto'; +import {generateBlock, validateDraft} from './generator'; +import {CmsBlockWorkflowError} from './errors'; +import type {CmsBlockRecord, CmsBlockDraft} from './types'; + +export class CmsBlockStore { + private readonly file: string; + private queue: Promise = Promise.resolve(); + + constructor(private readonly directory: string) { + this.file = join(directory, 'demo.json'); + } + + async get(): Promise { + await this.queue; + try { + return JSON.parse(await readFile(this.file, 'utf8')) as CmsBlockRecord; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } + } + + private async mutate(change: (current: CmsBlockRecord | null) => CmsBlockRecord): Promise { + const task = this.queue.then(async () => { + let current = null; + try { + current = JSON.parse(await readFile(this.file, 'utf8')); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + const next = change(current); + await mkdir(this.directory, {recursive: true}); + const tmp = join(this.directory, `.demo-${randomUUID()}.tmp`); + try { + await writeFile(tmp, JSON.stringify(next, null, 2) + '\n', {flag: 'wx', mode: 0o600}); + await rename(tmp, this.file); + } finally { + await rm(tmp, {force: true}); + } + return next; + }); + this.queue = task.catch(() => undefined); + return task; + } + + save(input: unknown): Promise { + const draft: CmsBlockDraft = validateDraft(input); + return this.mutate(current => ({ + id: 'demo', + ...draft, + revision: current?.revision ?? 0, + pending: null, + published: current?.published ?? null, + })); + } + + generate(): Promise { + return this.mutate(current => { + if (!current) throw new CmsBlockWorkflowError('Save the source content before generating.'); + const revision = current.revision + 1; + return { + ...current, + revision, + pending: { + ...generateBlock(validateDraft(current)), + templateId: current.templateId, + layoutId: validateDraft(current).layoutId, + revision, status: 'inreview', + }, + }; + }); + } + + approve(): Promise { + return this.mutate(current => { + if (!current?.pending) throw new CmsBlockWorkflowError('Generate a draft before approving.'); + return { + ...current, + published: {...current.pending, status: 'approved'}, + pending: null, + }; + }); + } + + reject(): Promise { + return this.mutate(current => { + if (!current?.pending) throw new CmsBlockWorkflowError('There is no draft to reject.'); + return {...current, pending: null}; + }); + } +} diff --git a/services/cmsblock/src/model/cmsblock/errors.ts b/services/cmsblock/src/model/cmsblock/errors.ts new file mode 100644 index 00000000..bf7cc658 --- /dev/null +++ b/services/cmsblock/src/model/cmsblock/errors.ts @@ -0,0 +1,7 @@ +export class CmsBlockValidationError extends Error { + constructor(message: string) { super(message); this.name = 'CmsBlockValidationError'; } +} + +export class CmsBlockWorkflowError extends Error { + constructor(message: string) { super(message); this.name = 'CmsBlockWorkflowError'; } +} diff --git a/services/cmsblock/src/model/cmsblock/generator.ts b/services/cmsblock/src/model/cmsblock/generator.ts new file mode 100644 index 00000000..fa4b5935 --- /dev/null +++ b/services/cmsblock/src/model/cmsblock/generator.ts @@ -0,0 +1,118 @@ +// Deterministic stand-in for the future AI editor. No user-authored HTML is executed. +// Hard-coded compositions corresponding to the three visual reference images. +// AI image interpretation will replace this mapping in a later iteration. +import type {CmsBlockDraft, CmsBlockLayoutId, CmsBlockSource, CmsBlockTemplateId} from './types'; +import {CmsBlockValidationError} from './errors'; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); +const isTemplateId = (value: unknown): value is CmsBlockTemplateId => + typeof value === 'string' && ['editorial', 'feature', 'promotion'].includes(value); +const isLayoutId = (value: unknown): value is CmsBlockLayoutId => + typeof value === 'string' && ['image-above', 'image-left', 'image-right'].includes(value); + +// Deterministic stand-in for a future AI model. Preserves legacy HTML/CSS. +const templates: Record = { + editorial: {background: '#F7F4EF', foreground: '#243F3A', accent: '#456A63'}, + feature: {background: '#F1F8F8', foreground: '#234453', accent: '#24717E'}, + promotion: {background: '#1D2C39', foreground: '#FFFFFF', accent: '#F2B65C'}, +}; + +const legacyLayouts: Record = {editorial: 'image-above', feature: 'image-left', promotion: 'image-right'}; + +export function validateDraft(value: unknown): CmsBlockDraft { + const input = isRecord(value) ? value : {}; + const source = isRecord(input.source) ? input.source : {}; + if ((source.format !== 'text' && source.format !== 'html') || + typeof source.content !== 'string' || + !source.content.trim() || source.content.length > 100_000 || + !isTemplateId(input.templateId) || + (input.layoutId !== undefined && !isLayoutId(input.layoutId))) { + throw new CmsBlockValidationError( + 'Provide content (up to 100,000 characters) and a valid template.' + ); + } + return { + source: {format: source.format, content: source.content}, + templateId: input.templateId, + layoutId: input.layoutId ?? legacyLayouts[input.templateId], + }; +} + +function escapeHtml(value: string): string { + return value.replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + +function extractText(source: CmsBlockSource): string { + if (source.format === 'text') return source.content; + return source.content + .replace(/<(script|style|iframe|object|svg)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, '') + .replace(/<\s*br\s*\/?\s*>/gi, '\n') + .replace(/<\s*\/\s*(p|h[1-6]|li|div|section)\s*>/gi, '\n\n') + .replace(/<[^>]*>/g, ' ') + .replace(/ /gi, ' ') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/gi, "'") + .replace(/&/gi, '&'); +} + +function extractImages(source: CmsBlockSource): string[] { + if (source.format !== 'html') return []; + const tags = source.content.match(/]*>/gi) ?? []; + return tags.slice(0, 3).flatMap(tag => { + const src = tag.match(/\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)')/i)?.slice(1).find(Boolean); + const alt = tag.match(/\balt\s*=\s*(?:"([^"]*)"|'([^']*)')/i)?.slice(1).find(v => v !== undefined) ?? ''; + try { + if (!src) return []; + const url = new URL(src); + if (url.protocol !== 'https:') return []; + return [`${escapeHtml(alt)}`]; + } catch { + return []; + } + }); +} + +export function generateBlock(draft: CmsBlockDraft): {html: string; css: string} { + const {source, templateId, layoutId} = validateDraft(draft); + const palette = templates[templateId]; + const lines = extractText(source).split(/\n+/).map(x => x.trim()).filter(Boolean); + const heading = escapeHtml(lines.shift() ?? 'Content block'); + const paragraphs = lines.map(line => `

${escapeHtml(line)}

`).join('\n'); + const images = extractImages(source); + const gallery = images.length ? `
${images.join('')}
` : ''; + // The data attribute is the boundary for generated CSS on standard DOM. + const html = `
+

${heading}

${paragraphs}
+ ${gallery} +
`; + const css = `[data-cmsblock="demo"] { + box-sizing: border-box; display: grid; grid-template-columns: minmax(0, 1fr); + gap: 1.25rem; padding: clamp(1.25rem, 4vw, 3rem); border-radius: 1rem; + background: ${palette.background}; color: ${palette.foreground}; font-family: system-ui, sans-serif; + border: 1px solid ${palette.accent}33; +} +[data-cmsblock="demo"] * { box-sizing: border-box; } +[data-cmsblock="demo"] h2 { color: ${palette.accent}; font-size: clamp(1.6rem, 3vw, 2.6rem); margin: 0 0 1rem; line-height: 1.15; } +[data-cmsblock="demo"] p { max-width: 68ch; margin: 0 0 0.85rem; line-height: 1.7; overflow-wrap: anywhere; } +[data-cmsblock="demo"] .cmsblock-media { margin: 0; display: flex; gap: 0.75rem; flex-wrap: wrap; } +[data-cmsblock="demo"] img { display: block; width: 100%; max-width: 100%; height: auto; max-height: 360px; border-radius: 0.75rem; object-fit: cover; } +[data-cmsblock="demo"].cmsblock-layout--image-above .cmsblock-media { grid-row: 1; } +[data-cmsblock="demo"].cmsblock-layout--image-above .cmsblock-copy { grid-row: 2; } +[data-cmsblock="demo"].cmsblock--promotion h2 { font-weight: 800; text-transform: uppercase; } +[data-cmsblock="demo"].cmsblock--promotion p { color: #E2E8F0; } +@media (min-width: 800px) { + [data-cmsblock="demo"].cmsblock-layout--image-left:has(.cmsblock-media), + [data-cmsblock="demo"].cmsblock-layout--image-right:has(.cmsblock-media) { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: center; + } + [data-cmsblock="demo"].cmsblock-layout--image-left .cmsblock-media { grid-column: 1; grid-row: 1; } + [data-cmsblock="demo"].cmsblock-layout--image-left .cmsblock-copy { grid-column: 2; grid-row: 1; } + [data-cmsblock="demo"].cmsblock-layout--image-right .cmsblock-copy { grid-column: 1; grid-row: 1; } + [data-cmsblock="demo"].cmsblock-layout--image-right .cmsblock-media { grid-column: 2; grid-row: 1; } +}`; + return {html, css}; +} diff --git a/services/cmsblock/src/model/cmsblock/types.ts b/services/cmsblock/src/model/cmsblock/types.ts new file mode 100644 index 00000000..cc13f65b --- /dev/null +++ b/services/cmsblock/src/model/cmsblock/types.ts @@ -0,0 +1,26 @@ +export type CmsBlockTemplateId = 'editorial' | 'feature' | 'promotion'; +export type CmsBlockLayoutId = 'image-above' | 'image-left' | 'image-right'; +export type CmsBlockSource = {format: 'text' | 'html'; content: string}; +export type CmsBlockDraft = { + source: CmsBlockSource; + templateId: CmsBlockTemplateId; + layoutId: CmsBlockLayoutId; +}; +export type CmsBlockRevision = { + html: string; + css: string; + revision: number; + status: 'inreview' | 'approved'; + // Optional in records persisted before style/layout metadata existed. + templateId?: CmsBlockTemplateId; + layoutId?: CmsBlockLayoutId; +}; +export type CmsBlockRecord = { + id: 'demo'; + source: CmsBlockSource; + templateId: CmsBlockTemplateId; + layoutId?: CmsBlockLayoutId; // Legacy persisted records have no layoutId. + revision: number; + pending: CmsBlockRevision | null; + published: CmsBlockRevision | null; +}; diff --git a/services/cmsblock/src/observability/activity.ts b/services/cmsblock/src/observability/activity.ts new file mode 100644 index 00000000..492ecc09 --- /dev/null +++ b/services/cmsblock/src/observability/activity.ts @@ -0,0 +1,45 @@ +import {trace, type Tracer, Attributes} from "@opentelemetry/api"; +import {OTLPTraceExporter} from '@opentelemetry/exporter-trace-otlp-http'; +import {resourceFromAttributes} from '@opentelemetry/resources'; +import {BatchSpanProcessor} from '@opentelemetry/sdk-trace-base'; +import {NodeTracerProvider} from '@opentelemetry/sdk-trace-node'; +import {config} from "../config"; +import {Operation} from "./operation"; + +export class OpenTelemetryObserver { + private readonly tracer: Tracer + + constructor() { + const exporter = new OTLPTraceExporter({ + url: `${config.observability.otelHost}/v1/traces` + }); + const provider = new NodeTracerProvider({ + resource: resourceFromAttributes({ + 'service.name': config.observability.serviceName, + 'service.version': '1.0.0' + }), + spanProcessors: [new BatchSpanProcessor(exporter)] + }); + + provider.register(); + this.tracer = trace.getTracer(config.observability.serviceName); + } + + startOperation(name: string, attributes: Attributes = {}): Operation { + return new Operation(this.tracer.startSpan(name, {attributes})); + } + + startChildOperation( + parent: Operation, + name: string, + attributes: Attributes = {} + ): Operation { + return new Operation( + this.tracer.startSpan(name, {attributes}, parent.getContext()) + ); + } + + logObservation(name: string, attributes: Attributes = {}): void { + this.startOperation(name, attributes).end(); + } +} diff --git a/services/cmsblock/src/observability/operation.ts b/services/cmsblock/src/observability/operation.ts new file mode 100644 index 00000000..a353361d --- /dev/null +++ b/services/cmsblock/src/observability/operation.ts @@ -0,0 +1,58 @@ +import { + Attributes, + AttributeValue, + context, + type Context, + Span, + SpanStatusCode, + trace +} from "@opentelemetry/api"; + +export class Operation { + private ended = false; + + constructor(private readonly span: Span) {} + + addEvent(name: string, attributes: Attributes = {}): void { + this.span.addEvent(name, attributes); + } + + setAttribute(key: string, value: AttributeValue): void { + this.span.setAttribute(key, value); + } + + getContext(): Context { + return trace.setSpan(context.active(), this.span); + } + + end(): void { + if (this.ended) return; + this.ended = true; + this.span.end(); + } + + succeed(): void { + if (this.ended) return; + this.span.setStatus({code: SpanStatusCode.OK}); + this.end(); + } + + complete(statusCode: number): void { + if (this.ended) return; + this.span.setAttribute('http.response.status_code', statusCode); + this.span.setStatus({ + code: statusCode >= 400 ? SpanStatusCode.ERROR : SpanStatusCode.OK, + message: statusCode >= 400 ? `HTTP ${statusCode}` : undefined + }); + this.end(); + } + + fail(error: unknown): void { + if (this.ended) return; + this.ended = true; + const err = error instanceof Error ? error : new Error(String(error)); + this.span.recordException(err); + this.span.setStatus({code: SpanStatusCode.ERROR, message: err.message}); + this.span.end(); + } +} diff --git a/services/cmsblock/src/observability/request-operation-middleware.ts b/services/cmsblock/src/observability/request-operation-middleware.ts new file mode 100644 index 00000000..1d6e744d --- /dev/null +++ b/services/cmsblock/src/observability/request-operation-middleware.ts @@ -0,0 +1,32 @@ +import {NextFunction, Request, RequestHandler, Response} from "express"; +import {sanitiseUrl} from "../lib/url"; +import {OpenTelemetryObserver} from "./activity"; +import {Operation} from "./operation"; + +export const createRequestOperationMiddleware = ( + operationName: string +): RequestHandler => (req: Request, res: Response, next: NextFunction): void => { + const telemetry = req.app.locals.telemetry as OpenTelemetryObserver; + const operation = telemetry.startOperation(operationName, { + 'http.request.method': req.method, + 'url.path': sanitiseUrl(req.originalUrl) + }); + + res.locals.requestOperation = operation; + res.once('finish', () => operation.complete(res.statusCode)); + res.once('close', () => { + if (!res.writableFinished) { + operation.fail(new Error('Response closed before completion.')); + } + }); + next(); +}; + +export const createRouteOperationMiddleware = ( + operationName: string +): RequestHandler => (req: Request, res: Response, next: NextFunction): void => { + const telemetry = req.app.locals.telemetry as OpenTelemetryObserver; + const parent = res.locals.requestOperation as Operation; + res.locals.routeOperation = telemetry.startChildOperation(parent, operationName); + next(); +}; diff --git a/services/cmsblock/src/observability/tracing.ts b/services/cmsblock/src/observability/tracing.ts new file mode 100644 index 00000000..bc5fcce3 --- /dev/null +++ b/services/cmsblock/src/observability/tracing.ts @@ -0,0 +1,6 @@ +import {Application} from "express"; +import {OpenTelemetryObserver} from "./activity"; + +export function setupTelemetry(app: Application) { + app.locals.telemetry = new OpenTelemetryObserver(); +} \ No newline at end of file diff --git a/services/cmsblock/src/routes/cmsblock-router.ts b/services/cmsblock/src/routes/cmsblock-router.ts new file mode 100644 index 00000000..571167bc --- /dev/null +++ b/services/cmsblock/src/routes/cmsblock-router.ts @@ -0,0 +1,22 @@ +import express from 'express'; +import type {Application} from 'express'; +import {config} from '../config'; +import {CmsBlockHandler} from '../controller/cmsblock-handler'; +import {corsOptions} from '../lib/cors-setup'; +import {createRouteOperationMiddleware} from '../observability/request-operation-middleware'; + +export function setupCmsBlockRoutes(app: Application): void { + const router = express.Router(); + const handler = new CmsBlockHandler(); + router.use(corsOptions()); + router.use((_req, res, next) => { + res.setHeader('Cache-Control', 'no-store'); + next(); + }); + router.get('/blocks/demo', createRouteOperationMiddleware('cmsblock.get'), handler.get); + router.put('/blocks/demo', createRouteOperationMiddleware('cmsblock.save'), handler.save); + router.post('/blocks/demo/generate', createRouteOperationMiddleware('cmsblock.generate'), handler.generate); + router.post('/blocks/demo/approve', createRouteOperationMiddleware('cmsblock.approve'), handler.approve); + router.post('/blocks/demo/reject', createRouteOperationMiddleware('cmsblock.reject'), handler.reject); + app.use(config.route.servicePrefix, router); +} diff --git a/services/cmsblock/src/routes/index.ts b/services/cmsblock/src/routes/index.ts new file mode 100644 index 00000000..01fa13a6 --- /dev/null +++ b/services/cmsblock/src/routes/index.ts @@ -0,0 +1,8 @@ +import type {Application} from 'express'; +import {setupStatusRoutes} from './status-router'; +import {setupCmsBlockRoutes} from './cmsblock-router'; + +export default function setupRoutes(app: Application): void { + setupStatusRoutes(app); + setupCmsBlockRoutes(app); +} diff --git a/services/cmsblock/src/routes/status-router.ts b/services/cmsblock/src/routes/status-router.ts new file mode 100644 index 00000000..137d0c53 --- /dev/null +++ b/services/cmsblock/src/routes/status-router.ts @@ -0,0 +1,19 @@ +import express, {Application} from 'express' +import {config} from "../config"; +import {StatusHandler} from "../controller/status-handler" +import {corsOptions} from '../lib/cors-setup' +import {createRouteOperationMiddleware} from "../observability/request-operation-middleware"; + +export const setupStatusRoutes = (app: Application) => { + const router = express.Router() + router.use(corsOptions()) + + const statusHandler = new StatusHandler() + router.get( + "/status", + createRouteOperationMiddleware('cmsblock.status'), + statusHandler.status + ) + + app.use(config.route.servicePrefix, router) +} diff --git a/services/cmsblock/test/flow.test.ts b/services/cmsblock/test/flow.test.ts new file mode 100644 index 00000000..e3b55543 --- /dev/null +++ b/services/cmsblock/test/flow.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; +import {mkdtemp, rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {once} from 'node:events'; +import type {Server} from 'node:http'; +import express from 'express'; +import {CmsBlockStore} from '../src/model/cmsblock/cmsblock-store'; +import {generateBlock, validateDraft} from '../src/model/cmsblock/generator'; +import {setupCmsBlockRoutes} from '../src/routes/cmsblock-router'; + +test('legacy CMSBlock routes preserve the author -> review -> publish lifecycle', async () => { + const directory = await mkdtemp(join(tmpdir(), 'reactedge-cmsblock-')); + const app = express(); + app.locals.cmsblocks = new CmsBlockStore(directory); + app.locals.telemetry = { + startChildOperation: () => ({succeed() {}, fail() {}, complete() {}}), + }; + // Route operation is a harmless test double; the production initializer sets up OTEL. + app.use((req, res, next) => { + res.locals.routeOperation = {succeed() {}, fail() {}, complete() {}}; + next(); + }); + app.use(express.json()); + // In production the same routes use the scaffolded OTEL middleware. + const {setupCmsBlockRoutes} = await import('../src/routes/cmsblock-router'); + setupCmsBlockRoutes(app); + // Tests provide dummy operations so routes can run without a collector. + const server: Server = app.listen(0, '127.0.0.1'); + await once(server, 'listening'); + try { + const addr = server.address(); + assert.ok(addr && typeof addr !== 'string'); + const base = `http://127.0.0.1:${addr.port}/cmsblock/blocks/demo`; + const request = async (suffix: string, method: string, body?: unknown) => { + const response = await fetch(base + suffix, { + method, headers: {'Content-Type': 'application/json'}, + ...(body === undefined ? {} : {body: JSON.stringify(body)}), + }); + return {status: response.status, data: await response.json()}; + }; + assert.equal((await request('', 'GET')).status, 404); + const draft = { + source: {format: 'html', content: '

Welcome

Our collection

Art'}, + templateId: 'editorial', layoutId: 'image-right', + }; + const saved = await request('', 'PUT', draft); + assert.equal(saved.status, 200); + assert.equal(saved.data.layoutId, 'image-right'); + + const review = await request('/generate', 'POST'); + assert.equal(review.status, 200); + assert.equal(review.data.pending.status, 'inreview'); + assert.match(review.data.pending.html, /cmsblock-layout--image-right/); + assert.equal(review.data.published, null); + + const approved = await request('/approve', 'POST'); + assert.equal(approved.data.published.revision, 1); + assert.equal(approved.data.pending, null); + const newer = await request('', 'PUT', {...draft, layoutId: 'image-left'}); + assert.equal(newer.data.published.revision, 1); + const refreshed = await request('/generate', 'POST'); + assert.equal(refreshed.data.pending.revision, 2); + assert.match(refreshed.data.pending.html, /cmsblock-layout--image-left/); + assert.equal(refreshed.data.published.revision, 1); + const rejected = await request('/reject', 'POST'); + assert.equal(rejected.data.pending, null); + assert.equal((await request('', 'GET')).data.published.revision, 1); + + assert.equal((await request('', 'PUT', {source: {format: 'text', content: ''}, templateId: 'feature'})).status, 400); + } finally { + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + await rm(directory, {recursive: true, force: true}); + } +}); + +test('legacy validation, HTML escaping and style-layout combinations', () => { + const source = {format: 'html' as const, + content: '

Hello

Text

Art'}; + const left = generateBlock({source, templateId: 'promotion', layoutId: 'image-left'}); + assert.match(left.html, /cmsblock-layout--image-left/); + assert.match(left.html, /https:\/\/example.com\/photo.jpg/); + assert.doesNotMatch(left.html, / + + + + + \ No newline at end of file diff --git a/widgets/cmsblock/package.json b/widgets/cmsblock/package.json new file mode 100644 index 00000000..2ea24f74 --- /dev/null +++ b/widgets/cmsblock/package.json @@ -0,0 +1,39 @@ +{ + "name": "widget-cmsblock", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "build:ssr": "vite build -c vite.npm.config.ts", + "analyze": "ANALYZE=true vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react-swc": "^4.2.2", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "postcss": "^8.5.6", + "postcss-prefix-selector": "^2.1.1", + "rollup-plugin-visualizer": "^7.0.1", + "tsx": "^4.21.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.46.4", + "vite": "^8.3.0" + }, + "peerDependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + } +} diff --git a/widgets/cmsblock/postcss.config.cjs b/widgets/cmsblock/postcss.config.cjs new file mode 100644 index 00000000..a6076193 --- /dev/null +++ b/widgets/cmsblock/postcss.config.cjs @@ -0,0 +1,15 @@ +const prefixSelector = require('postcss-prefix-selector'); + +module.exports = { + plugins: [ + prefixSelector({ + prefix: '.reactedge-cmsblock', + transform(prefix, selector, prefixedSelector) { + if (selector.startsWith('html') || selector.startsWith('body')) { + return selector; + } + return prefixedSelector; + }, + }), + ], +}; diff --git a/widgets/cmsblock/public/default.json b/widgets/cmsblock/public/default.json new file mode 100644 index 00000000..9c7f4455 --- /dev/null +++ b/widgets/cmsblock/public/default.json @@ -0,0 +1,13 @@ +{ + "data": { + "title": "CMS Block", + "source": { + "format": "text", + "content": "Add your content here. Select a template to prepare it for AI-assisted styling." + }, + "templateId": "editorial" + }, + "settings": { + "colour": "#1f2937" + } +} diff --git a/widgets/cmsblock/src/Config.ts b/widgets/cmsblock/src/Config.ts new file mode 100644 index 00000000..1c4c56d0 --- /dev/null +++ b/widgets/cmsblock/src/Config.ts @@ -0,0 +1,26 @@ +import {parseConfig, type WidgetConfig as ParsedWidgetConfig} from './ConfigSchema.ts'; +import type {WidgetActivity} from '@reactedge/framework/activity'; + +export type WidgetConfig = ParsedWidgetConfig; + +export const WIDGET_ID = 'cmsblock'; + +/** CMSBlock is a static widget; editing will use a separate backend service. */ +export function readWidgetConfig( + rawContract: unknown, + activity?: WidgetActivity +): WidgetConfig { + try { + const config = parseConfig(rawContract); + activity?.log('bootstrap', 'Config resolved', config); + return Object.freeze(config); + } catch (error) { + activity?.log( + 'bootstrap', + 'Invalid widget contract', + error instanceof Error ? error.message : error, + 'error' + ); + throw error; + } +} diff --git a/widgets/cmsblock/src/ConfigSchema.ts b/widgets/cmsblock/src/ConfigSchema.ts new file mode 100644 index 00000000..ab15745e --- /dev/null +++ b/widgets/cmsblock/src/ConfigSchema.ts @@ -0,0 +1,29 @@ +import {z} from 'zod'; + +const WidgetSourceSchema = z.object({ + format: z.enum(['text', 'html']), + content: z.string().max(100_000), +}).strict(); + +const WidgetDataSchema = z.object({ + title: z.string().min(1), + source: WidgetSourceSchema.default({format: 'text', content: ''}), + templateId: z.enum(['editorial', 'feature', 'promotion']).default('editorial'), + // Optional for existing widget contracts. + layoutId: z.enum(['image-above', 'image-left', 'image-right']).optional(), +}).strict(); + +const WidgetSettingsSchema = z.object({ + colour: z.string(), +}).strict(); + +export const WidgetConfigSchema = z.object({ + data: WidgetDataSchema, + settings: WidgetSettingsSchema, +}).strict(); + +export type WidgetConfig = z.infer; + +export function parseConfig(input: unknown): WidgetConfig { + return WidgetConfigSchema.parse(input); +} diff --git a/widgets/cmsblock/src/Model/CmsBlock.ts b/widgets/cmsblock/src/Model/CmsBlock.ts new file mode 100644 index 00000000..fc6e57b8 --- /dev/null +++ b/widgets/cmsblock/src/Model/CmsBlock.ts @@ -0,0 +1,30 @@ +/** Source is preserved exactly as authored; editing it does not execute HTML. */ +export type CmsBlockSource = { + format: 'text' | 'html'; + content: string; +}; + +/** + * Style is the saved template dimension. + * The IDs remain unchanged to preserve the current CMSBlock service contract. + * 'feature' is retained as the API ID for the Minimal style. + */ +export const CMS_BLOCK_STYLES = [ + {id: 'editorial', label: 'Editorial', description: 'Warm, comfortable reading'}, + {id: 'feature', label: 'Minimal', description: 'Clean, understated presentation'}, + {id: 'promotion', label: 'Promotional', description: 'Bold, high-contrast emphasis'}, +] as const; + +export type CmsBlockTemplateId = (typeof CMS_BLOCK_STYLES)[number]['id']; +import type {CmsBlockLayoutId} from './CmsBlockLayout.ts'; + +/** Existing saved blocks had an implied image arrangement based on style. */ +export function legacyLayoutForTemplate(id: CmsBlockTemplateId): CmsBlockLayoutId { + return id === 'feature' ? 'image-left' : id === 'promotion' ? 'image-right' : 'image-above'; +} + +export type CmsBlockDraft = { + source: CmsBlockSource; + templateId: CmsBlockTemplateId; + layoutId: CmsBlockLayoutId; +}; diff --git a/widgets/cmsblock/src/Model/CmsBlockApi.ts b/widgets/cmsblock/src/Model/CmsBlockApi.ts new file mode 100644 index 00000000..4db307c5 --- /dev/null +++ b/widgets/cmsblock/src/Model/CmsBlockApi.ts @@ -0,0 +1,48 @@ +import type {CmsBlockDraft} from './CmsBlock.ts'; + +export interface CmsBlockRevision { + html: string; + css: string; + revision: number; + status: 'inreview' | 'approved'; + layoutId?: CmsBlockDraft['layoutId']; +} + +export interface CmsBlockRecord extends CmsBlockDraft { + id: 'demo'; + revision: number; + pending: CmsBlockRevision | null; + published: CmsBlockRevision | null; +} + +const base = `${(import.meta.env.VITE_CMSBLOCK_URL || 'http://127.0.0.1:4190').replace(/\/+$/, '')}/cmsblock/blocks/demo`; + +async function request(path: string, method: 'GET' | 'PUT' | 'POST', body?: CmsBlockDraft): Promise { + let response: Response; + try { + response = await fetch(base + path, { + method, + headers: {'Content-Type': 'application/json'}, + ...(body ? {body: JSON.stringify(body)} : {}), + cache: 'no-store', + }); + } catch { + throw new Error('Cannot reach the CMSBlock service. Start services/cmsblock on port 4190.'); + } + if (response.status === 404 && method === 'GET') return null; + const data: unknown = await response.json(); + if (!response.ok) { + const error = typeof data === 'object' && data !== null && 'error' in data && + typeof data.error === 'string' ? data.error : 'CMSBlock service request failed.'; + throw new Error(error); + } + return data as CmsBlockRecord; +} + +export const CmsBlockApi = { + get: () => request('', 'GET'), + save: (draft: CmsBlockDraft) => request('', 'PUT', draft), + generate: () => request('/generate', 'POST'), + approve: () => request('/approve', 'POST'), + reject: () => request('/reject', 'POST'), +}; diff --git a/widgets/cmsblock/src/Model/CmsBlockLayout.ts b/widgets/cmsblock/src/Model/CmsBlockLayout.ts new file mode 100644 index 00000000..3e045a16 --- /dev/null +++ b/widgets/cmsblock/src/Model/CmsBlockLayout.ts @@ -0,0 +1,8 @@ +/** Independent layout choices: UI preview only until the service supports layoutId. */ +export const CMS_BLOCK_LAYOUTS = [ + {id: 'image-above', label: 'Image above', description: 'Image above the content'}, + {id: 'image-left', label: 'Image left', description: 'Image to the left of the content'}, + {id: 'image-right', label: 'Image right', description: 'Image to the right of the content'}, +] as const; + +export type CmsBlockLayoutId = (typeof CMS_BLOCK_LAYOUTS)[number]['id']; diff --git a/widgets/cmsblock/src/Model/CmsBlockLayoutImages.ts b/widgets/cmsblock/src/Model/CmsBlockLayoutImages.ts new file mode 100644 index 00000000..75c263f8 --- /dev/null +++ b/widgets/cmsblock/src/Model/CmsBlockLayoutImages.ts @@ -0,0 +1,55 @@ +import type {CmsBlockLayoutId} from './CmsBlockLayout.ts'; + +/** + * Hard-coded layout illustrations. They do not define editorial style. + * Bundled as data images so the gallery works on any ReactEdge host. + */ +const imageAbove = ` + + + + + + + + + +`; + +const imageLeft = ` + + + + + + + + + + + + + +`; + +const imageRight = ` + + + + + + + + + + +`; + +const asImage = (svg: string) => `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; + +// Purely visual selectors in this iteration; the backend has no layoutId yet. +export const CMS_BLOCK_LAYOUT_IMAGES: Record = { + 'image-above': asImage(imageAbove), + 'image-left': asImage(imageLeft), + 'image-right': asImage(imageRight), +}; diff --git a/widgets/cmsblock/src/Widget.tsx b/widgets/cmsblock/src/Widget.tsx new file mode 100644 index 00000000..175b7104 --- /dev/null +++ b/widgets/cmsblock/src/Widget.tsx @@ -0,0 +1,20 @@ +import {WidgetRoot} from "./bootstrap/widget-root.tsx"; +import {createRoot, hydrateRoot} from "react-dom/client"; +import type {StaticWidgetOptions} from "@reactedge/public-api/WidgetOptions.ts"; + +export function Widget({ + container, + contract, + bootstrap, + hydrate = false, + }: StaticWidgetOptions) { + const element = ( + + ); + + if (hydrate) { + hydrateRoot(container, element); + } else { + createRoot(container).render(element); + } +} \ No newline at end of file diff --git a/widgets/cmsblock/src/WidgetView.tsx b/widgets/cmsblock/src/WidgetView.tsx new file mode 100644 index 00000000..5662304c --- /dev/null +++ b/widgets/cmsblock/src/WidgetView.tsx @@ -0,0 +1,15 @@ +import {readWidgetConfig} from "./Config.ts"; +import {WidgetCmsblock} from "./components/WidgetCmsblock.tsx"; + +type Props = { + contract?: unknown; +}; + +export const WidgetView = ({ contract }: Props) => { + const config = readWidgetConfig(contract); + + if (!config) return null; + + return +}; + diff --git a/widgets/cmsblock/src/activity/Context/ActivityContext.tsx b/widgets/cmsblock/src/activity/Context/ActivityContext.tsx new file mode 100644 index 00000000..81be6cc3 --- /dev/null +++ b/widgets/cmsblock/src/activity/Context/ActivityContext.tsx @@ -0,0 +1,4 @@ +import {createContext} from "react"; +import type {WidgetActivity} from "@reactedge/framework/activity"; + +export const LocalInstanceStateContext = createContext(undefined); \ No newline at end of file diff --git a/widgets/cmsblock/src/activity/Context/ActivityContextProvider.tsx b/widgets/cmsblock/src/activity/Context/ActivityContextProvider.tsx new file mode 100644 index 00000000..e1afaf74 --- /dev/null +++ b/widgets/cmsblock/src/activity/Context/ActivityContextProvider.tsx @@ -0,0 +1,31 @@ +import {type ReactNode} from "react"; +import {WIDGET_ID} from "../../Config.ts"; +import {LocalInstanceStateContext} from "./ActivityContext.tsx"; +import {WidgetActivity} from "@reactedge/framework/activity"; + +interface InstanceStateProviderProps { + children: ReactNode; + hostElement?: HTMLElement; +} + +const LocalStateProvider = LocalInstanceStateContext.Provider; + +export const ActivityContextProvider: React.FC = ({ + children, + hostElement + }) => { + + const host = hostElement ?? document.documentElement; + + const activity = new WidgetActivity(WIDGET_ID, host.dataset.instance) + + return ( + + {children} + + ); +}; diff --git a/widgets/cmsblock/src/activity/Context/useActivityContext.ts b/widgets/cmsblock/src/activity/Context/useActivityContext.ts new file mode 100644 index 00000000..7a61a77d --- /dev/null +++ b/widgets/cmsblock/src/activity/Context/useActivityContext.ts @@ -0,0 +1,11 @@ +import {useContext} from "react"; +import {LocalInstanceStateContext} from "./ActivityContext.tsx"; +import type {WidgetActivity} from "@reactedge/framework/activity"; + +export function useActivityContext(): WidgetActivity { + const context = useContext(LocalInstanceStateContext); + if (!context) { + throw new Error("useInstanceState must be used within InstanceStateProvider"); + } + return context; +} \ No newline at end of file diff --git a/widgets/cmsblock/src/bootstrap/WidgetWrapper.tsx b/widgets/cmsblock/src/bootstrap/WidgetWrapper.tsx new file mode 100644 index 00000000..2e095ceb --- /dev/null +++ b/widgets/cmsblock/src/bootstrap/WidgetWrapper.tsx @@ -0,0 +1,18 @@ +import {WidgetCmsblock} from "../components/WidgetCmsblock.tsx"; +import {readWidgetConfig} from "../Config.ts"; +import {useActivityContext} from "../activity/Context/useActivityContext.ts"; + +type Props = { + contract?: unknown + bootstrap?: unknown +}; + +export const WidgetWrapper = ({ contract }: Props) => { + const activity = useActivityContext() + const config = readWidgetConfig(contract, activity); + + if (!config) return null; + + return +}; + diff --git a/widgets/cmsblock/src/bootstrap/widget-root.tsx b/widgets/cmsblock/src/bootstrap/widget-root.tsx new file mode 100644 index 00000000..b0c91cd3 --- /dev/null +++ b/widgets/cmsblock/src/bootstrap/widget-root.tsx @@ -0,0 +1,24 @@ +import {WIDGET_ID} from "../Config.ts"; +import {ActivityContextProvider} from "../activity/Context/ActivityContextProvider.tsx"; +import {WidgetWrapper} from "./WidgetWrapper.tsx"; +interface WidgetRootProps { + contract: unknown; + bootstrap: unknown; + hostElement?: HTMLElement; +} + +export function WidgetRoot({ + contract, + bootstrap, + hostElement, + }: WidgetRootProps) { + return ( +
+ + + +
+ ); +} \ No newline at end of file diff --git a/widgets/cmsblock/src/bootstrap/widget-ssr-component.tsx b/widgets/cmsblock/src/bootstrap/widget-ssr-component.tsx new file mode 100644 index 00000000..bf7328f6 --- /dev/null +++ b/widgets/cmsblock/src/bootstrap/widget-ssr-component.tsx @@ -0,0 +1,16 @@ +import {WIDGET_ID} from "../Config.ts"; +import {WidgetView} from "../WidgetView.tsx"; +interface WidgetRootProps { + contract: unknown; + hostElement?: HTMLElement; +} + +export function WidgetComponent({ + contract + }: WidgetRootProps) { + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/widgets/cmsblock/src/components/CmsBlockEditor.tsx b/widgets/cmsblock/src/components/CmsBlockEditor.tsx new file mode 100644 index 00000000..f1180915 --- /dev/null +++ b/widgets/cmsblock/src/components/CmsBlockEditor.tsx @@ -0,0 +1,34 @@ +import {useId} from 'react'; +import type {CmsBlockDraft} from '../Model/CmsBlock.ts'; +import {CmsBlockStylePicker} from './CmsBlockStylePicker.tsx'; +import {CmsBlockLayoutPicker} from './CmsBlockLayoutPicker.tsx'; +import type {CmsBlockController} from '../controller/useCmsBlockController.ts'; + +type Props = {controller: CmsBlockController}; + +export const CmsBlockEditor = ({controller}: Props) => { + const contentId = useId(); + const formatId = useId(); + const {draft} = controller; + + return ( +
+ +