openvibers/openvibe.directory Commits

Launch sourced directories with scheduled refresh (#1)

OP OpenVibers committed 2 days ago · e483eb3f25 · 13 files, +1762 −1

Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..b6d7ac0
---- /dev/null
++++ b/.env.example
@@@ -0,0 +1,2 @@
++PORT=5281
++BASE_URL=https://openvibe.directory
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..8b4ecac
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -0,0 +1,18 @@
++name: ci
++on:
++ push:
++ branches: [main]
++ pull_request:
++jobs:
++ test:
++ runs-on: ubuntu-latest
++ steps:
++ - uses: actions/checkout@v4
++ - uses: actions/setup-node@v4
++ with:
++ node-version: 22
++ cache: npm
++ - run: npm ci --ignore-scripts
++ - run: npm test
++ security:
++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@15b927b7d9fe35fe64ee4b7f3b4792b46ebdbddb
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..27a2084
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,3 @@
++node_modules/
++.env
++coverage/
ddiff --git a/README.md b/README.md
iindex 9b50c5d..8a24003 100644
---- a/README.md
++++ b/README.md
@@@ -1,2 +1,7 @@
# OpenVibe.Directory
--Source-linked directories that keep themselves current
++
++Source-linked directories for OpenVibe sites and tools, AI models and providers, open-source projects, and local businesses from OpenStreetMap. Every entry has a source and a last checked date. Public sources are refreshed at startup and every six hours; a failed source keeps its last successful in-memory snapshot and exposes the failure in `/api/v1/kinds`.
++
++Business listings are a bounded San Francisco pilot sourced from OpenStreetMap under ODbL 1.0. Phone numbers appear only when published on the mapped business. Private-person searches and reverse lookups are excluded. The people directory has no imported entries and remains empty until the opt-in claim flow is available.
++
++Run `npm ci`, then `node server/index.js`. Check `/api/v1/kinds`, `/api/v1/entries`, `/sitemap.xml`, and `/llms.txt`.
ddiff --git a/deploy/nginx/openvibe.directory.conf b/deploy/nginx/openvibe.directory.conf
nnew file mode 100644
iindex 0000000..ad85100
---- /dev/null
++++ b/deploy/nginx/openvibe.directory.conf
@@@ -0,0 +1,36 @@
++server {
++ listen 80;
++ listen [::]:80;
++ server_name openvibe.directory www.openvibe.directory;
++ location /.well-known/acme-challenge/ { root /var/www/certbot; }
++ location / { return 301 https://openvibe.directory$request_uri; }
++}
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name www.openvibe.directory;
++ ssl_certificate /etc/letsencrypt/live/openvibe.directory/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.directory/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++ return 301 https://openvibe.directory$request_uri;
++}
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name openvibe.directory;
++ ssl_certificate /etc/letsencrypt/live/openvibe.directory/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.directory/privkey.pem;
++ ssl_protocols TLSv1.2 TLSv1.3;
++ proxy_http_version 1.1;
++ proxy_set_header Host $host;
++ proxy_set_header X-Real-IP $remote_addr;
++ proxy_set_header X-Forwarded-For $remote_addr;
++ proxy_set_header CF-Connecting-IP $remote_addr;
++ proxy_set_header X-Forwarded-Proto $scheme;
++ location ~* ^/internal(/|$) { return 404; }
++ location ~* ^/metrics(/|$) { return 404; }
++ location / { proxy_pass http://127.0.0.1:5281; }
++ location ~ /\.(?!well-known) { deny all; }
++}
ddiff --git a/deploy/systemd/openvibe-directory.service b/deploy/systemd/openvibe-directory.service
nnew file mode 100644
iindex 0000000..cecbe4a
---- /dev/null
++++ b/deploy/systemd/openvibe-directory.service
@@@ -0,0 +1,23 @@
++[Unit]
++Description=OpenVibe.Directory
++After=network.target
++[Service]
++Type=simple
++User=ubuntu
++Group=ubuntu
++WorkingDirectory=/opt/openvibe.directory
++EnvironmentFile=/etc/openvibe/directory.env
++Environment=NODE_ENV=production
++Environment=PORT=5281
++Environment=PATH=/usr/local/bin:/usr/bin:/bin
++ExecStart=/usr/bin/env node /opt/openvibe.directory/server/index.js
++Restart=always
++RestartSec=5
++KillSignal=SIGTERM
++TimeoutStopSec=15
++NoNewPrivileges=true
++PrivateTmp=true
++ProtectSystem=strict
++ProtectHome=true
++[Install]
++WantedBy=multi-user.target
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..1771914
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,1501 @@
++{
++ "name": "openvibe-directory",
++ "version": "0.1.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-directory",
++ "version": "0.1.0",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "express": "^4.21.2",
++ "fast-xml-parser": "^5.2.5",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0"
++ },
++ "engines": {
++ "node": ">=22"
++ }
++ },
++ "node_modules/@jridgewell/gen-mapping": {
++ "version": "0.3.13",
++ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
++ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
++ "license": "MIT",
++ "dependencies": {
++ "@jridgewell/sourcemap-codec": "^1.5.0",
++ "@jridgewell/trace-mapping": "^0.3.24"
++ }
++ },
++ "node_modules/@jridgewell/resolve-uri": {
++ "version": "3.1.2",
++ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
++ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=6.0.0"
++ }
++ },
++ "node_modules/@jridgewell/source-map": {
++ "version": "0.3.11",
++ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
++ "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
++ "license": "MIT",
++ "dependencies": {
++ "@jridgewell/gen-mapping": "^0.3.5",
++ "@jridgewell/trace-mapping": "^0.3.25"
++ }
++ },
++ "node_modules/@jridgewell/sourcemap-codec": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
++ "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
++ "license": "MIT"
++ },
++ "node_modules/@jridgewell/trace-mapping": {
++ "version": "0.3.31",
++ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
++ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
++ "license": "MIT",
++ "dependencies": {
++ "@jridgewell/resolve-uri": "^3.1.0",
++ "@jridgewell/sourcemap-codec": "^1.4.14"
++ }
++ },
++ "node_modules/@nodable/entities": {
++ "version": "3.1.0",
++ "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz",
++ "integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/nodable"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/accepts": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-types": "~2.1.34",
++ "negotiator": "0.6.3"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/acorn": {
++ "version": "8.19.0",
++ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
++ "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==",
++ "license": "MIT",
++ "bin": {
++ "acorn": "bin/acorn"
++ },
++ "engines": {
++ "node": ">=0.4.0"
++ }
++ },
++ "node_modules/anynum": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
++ "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/NaturalIntelligence"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/array-flatten": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
++ "license": "MIT"
++ },
++ "node_modules/body-parser": {
++ "version": "1.20.9",
++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz",
++ "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "content-type": "~1.0.5",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "~1.2.0",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "on-finished": "~2.4.1",
++ "qs": "~6.16.0",
++ "raw-body": "~2.5.3",
++ "type-is": "~1.6.18",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/boolbase": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
++ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
++ "license": "ISC",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
++ "node_modules/buffer-equal-constant-time": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/buffer-from": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
++ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
++ "license": "MIT"
++ },
++ "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/clean-css": {
++ "version": "5.3.3",
++ "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz",
++ "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==",
++ "license": "MIT",
++ "dependencies": {
++ "source-map": "~0.6.0"
++ },
++ "engines": {
++ "node": ">= 10.0"
++ }
++ },
++ "node_modules/commander": {
++ "version": "2.20.3",
++ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
++ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
++ "license": "MIT"
++ },
++ "node_modules/content-disposition": {
++ "version": "0.5.4",
++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "5.2.1"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "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.0.7",
++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
++ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
++ "license": "MIT"
++ },
++ "node_modules/css-select": {
++ "version": "7.0.0",
++ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
++ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "boolbase": "^2.0.0",
++ "css-what": "^8.0.0",
++ "domhandler": "^6.0.1",
++ "domutils": "^4.0.2",
++ "nth-check": "^3.0.1"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
++ "node_modules/css-what": {
++ "version": "8.0.0",
++ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
++ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
++ "node_modules/cssomnom": {
++ "version": "0.1.11",
++ "resolved": "https://registry.npmjs.org/cssomnom/-/cssomnom-0.1.11.tgz",
++ "integrity": "sha512-P4RkO1Sq2Ual77xypEmAc4MkM5k2Ha4UwtBPCOpJmXculGCYPgzsU/xnOCy084esZaPePgGkkJg4Il/HE7GS5A==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/debug": {
++ "version": "2.6.9",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "2.0.0"
++ }
++ },
++ "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/destroy": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/dom-serializer": {
++ "version": "3.1.1",
++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
++ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
++ "license": "MIT",
++ "dependencies": {
++ "domelementtype": "^3.0.0",
++ "domhandler": "^6.0.0",
++ "entities": "^8.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
++ }
++ },
++ "node_modules/domelementtype": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
++ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ ],
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ }
++ },
++ "node_modules/domhandler": {
++ "version": "6.0.1",
++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
++ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "domelementtype": "^3.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/fb55/domhandler?sponsor=1"
++ }
++ },
++ "node_modules/domutils": {
++ "version": "4.0.2",
++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
++ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "dom-serializer": "^3.0.0",
++ "domelementtype": "^3.0.0",
++ "domhandler": "^6.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/fb55/domutils?sponsor=1"
++ }
++ },
++ "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/ecdsa-sig-formatter": {
++ "version": "1.0.11",
++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "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.1",
++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.1.tgz",
++ "integrity": "sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/entities": {
++ "version": "8.1.0",
++ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
++ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/entities?sponsor=1"
++ }
++ },
++ "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/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": "4.22.3",
++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
++ "license": "MIT",
++ "dependencies": {
++ "accepts": "~1.3.8",
++ "array-flatten": "1.1.1",
++ "body-parser": "~1.20.5",
++ "content-disposition": "~0.5.4",
++ "content-type": "~1.0.4",
++ "cookie": "~0.7.1",
++ "cookie-signature": "~1.0.6",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "finalhandler": "~1.3.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.0",
++ "merge-descriptors": "1.0.3",
++ "methods": "~1.1.2",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "path-to-regexp": "~0.1.13",
++ "proxy-addr": "~2.0.7",
++ "qs": "~6.16.0",
++ "range-parser": "~1.2.1",
++ "safe-buffer": "5.2.1",
++ "send": "~0.19.0",
++ "serve-static": "~1.16.2",
++ "setprototypeof": "1.2.0",
++ "statuses": "~2.0.1",
++ "type-is": "~1.6.18",
++ "utils-merge": "1.0.1",
++ "vary": "~1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.10.0"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/fast-xml-builder": {
++ "version": "1.3.1",
++ "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz",
++ "integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/NaturalIntelligence"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "path-expression-matcher": "^1.6.2",
++ "xml-naming": "^0.3.0"
++ }
++ },
++ "node_modules/fast-xml-parser": {
++ "version": "5.12.0",
++ "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.12.0.tgz",
++ "integrity": "sha512-/pXxRBtwxY9w8z7YbgsvIPPcPvFDsTbIkWwTQJhzNJPokj+TbN6ftmcv4j/pEQDvOCNnKGDzuk5Bo2oogIOiFg==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/NaturalIntelligence"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "@nodable/entities": "^3.1.0",
++ "fast-xml-builder": "^1.3.1",
++ "is-unsafe": "^2.0.3",
++ "path-expression-matcher": "^1.6.2",
++ "strnum": "^2.5.1",
++ "xml-naming": "^0.3.0"
++ },
++ "bin": {
++ "fxparser": "src/cli/cli.js"
++ }
++ },
++ "node_modules/finalhandler": {
++ "version": "1.3.2",
++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "statuses": "~2.0.2",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "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": "0.5.2",
++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "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/html-escaper": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
++ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
++ "license": "MIT"
++ },
++ "node_modules/htmlparser2": {
++ "version": "10.1.0",
++ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
++ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
++ "funding": [
++ "https://github.com/fb55/htmlparser2?sponsor=1",
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "domelementtype": "^2.3.0",
++ "domhandler": "^5.0.3",
++ "domutils": "^3.2.2",
++ "entities": "^7.0.1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/dom-serializer": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
++ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
++ "license": "MIT",
++ "dependencies": {
++ "domelementtype": "^2.3.0",
++ "domhandler": "^5.0.2",
++ "entities": "^4.2.0"
++ },
++ "funding": {
++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {
++ "version": "4.5.0",
++ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
++ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=0.12"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/entities?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/domelementtype": {
++ "version": "2.3.0",
++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
++ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ ],
++ "license": "BSD-2-Clause"
++ },
++ "node_modules/htmlparser2/node_modules/domhandler": {
++ "version": "5.0.3",
++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
++ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "domelementtype": "^2.3.0"
++ },
++ "engines": {
++ "node": ">= 4"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/domhandler?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/domutils": {
++ "version": "3.2.2",
++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
++ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "dom-serializer": "^2.0.0",
++ "domelementtype": "^2.3.0",
++ "domhandler": "^5.0.3"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/domutils?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/entities": {
++ "version": "7.0.1",
++ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
++ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=0.12"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/entities?sponsor=1"
++ }
++ },
++ "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.4.24",
++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
++ "license": "MIT",
++ "dependencies": {
++ "safer-buffer": ">= 2.1.2 < 3"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "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-unsafe": {
++ "version": "2.0.3",
++ "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.3.tgz",
++ "integrity": "sha512-ZBllczCCO267vgdFb6Tq3pv+LhHaCiGMdr+ZsuL+OopruAg7S+3ZDs1vD0RPC0uSrkjzbraxaGjtX1YBxjPeFQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/NaturalIntelligence"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/jsonwebtoken": {
++ "version": "9.0.3",
++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
++ "license": "MIT",
++ "dependencies": {
++ "jws": "^4.0.1",
++ "lodash.includes": "^4.3.0",
++ "lodash.isboolean": "^3.0.3",
++ "lodash.isinteger": "^4.0.4",
++ "lodash.isnumber": "^3.0.3",
++ "lodash.isplainobject": "^4.0.6",
++ "lodash.isstring": "^4.0.1",
++ "lodash.once": "^4.0.0",
++ "ms": "^2.1.1",
++ "semver": "^7.5.4"
++ },
++ "engines": {
++ "node": ">=12",
++ "npm": ">=6"
++ }
++ },
++ "node_modules/jsonwebtoken/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/jwa": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer-equal-constant-time": "^1.0.1",
++ "ecdsa-sig-formatter": "1.0.11",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/jws": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
++ "license": "MIT",
++ "dependencies": {
++ "jwa": "^2.0.1",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/linkedom": {
++ "version": "0.18.15",
++ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.15.tgz",
++ "integrity": "sha512-o0w7eD/SvoCao+pUi6dzocCLuMR11g4Tv/et0W2OUzwWmUcewntSzyl+Ua/K+0yKym0Y/Mav3nFAaI5Av5jvZg==",
++ "license": "ISC",
++ "dependencies": {
++ "css-select": "^7.0.0",
++ "cssomnom": "^0.1.11",
++ "html-escaper": "^3.0.3",
++ "htmlparser2": "^10.1.0",
++ "uhyphen": "^0.2.0"
++ },
++ "engines": {
++ "node": ">=16"
++ },
++ "peerDependencies": {
++ "canvas": ">= 2"
++ },
++ "peerDependenciesMeta": {
++ "canvas": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/lodash.includes": {
++ "version": "4.3.0",
++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isboolean": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isinteger": {
++ "version": "4.0.4",
++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isnumber": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isplainobject": {
++ "version": "4.0.6",
++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isstring": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.once": {
++ "version": "4.1.1",
++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
++ "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": "0.3.0",
++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/merge-descriptors": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/methods": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
++ "license": "MIT",
++ "bin": {
++ "mime": "cli.js"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/mime-db": {
++ "version": "1.52.0",
++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime-types": {
++ "version": "2.1.35",
++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-db": "1.52.0"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/ms": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
++ "license": "MIT"
++ },
++ "node_modules/negotiator": {
++ "version": "0.6.3",
++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/nth-check": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
++ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "boolbase": "^2.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/fb55/nth-check?sponsor=1"
++ }
++ },
++ "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.2",
++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",
++ "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==",
++ "license": "MIT",
++ "dependencies": {
++ "ee-first": "1.1.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/openvibe-shared": {
++ "version": "4.202.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0",
++ "integrity": "sha512-lSZavwvUc8nI7eS7ZzLjVaz2d2xVmvYGh81H83d+i1LPp7cbS7eK9HCiQS8ESK9N8eH0i/Ok+sqBFxeUwsnVPQ==",
++ "license": "MIT",
++ "dependencies": {
++ "clean-css": "^5.3.3",
++ "jsonwebtoken": "^9.0.0",
++ "linkedom": "^0.18.13",
++ "terser": "^5.51.2"
++ },
++ "bin": {
++ "openvibe-build-assets": "scripts/build-assets.js"
++ }
++ },
++ "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-expression-matcher": {
++ "version": "1.6.2",
++ "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
++ "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/NaturalIntelligence"
++ }
++ ],
++ "license": "MIT",
++ "engines": {
++ "node": ">=14.0.0"
++ }
++ },
++ "node_modules/path-to-regexp": {
++ "version": "0.1.13",
++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
++ "license": "MIT"
++ },
++ "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.2",
++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",
++ "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==",
++ "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.2.1",
++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/raw-body": {
++ "version": "2.5.3",
++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/safe-buffer": {
++ "version": "5.2.1",
++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "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/semver": {
++ "version": "7.8.6",
++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz",
++ "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==",
++ "license": "ISC",
++ "bin": {
++ "semver": "bin/semver.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/send": {
++ "version": "0.19.2",
++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "1.2.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.1",
++ "mime": "1.6.0",
++ "ms": "2.1.3",
++ "on-finished": "~2.4.1",
++ "range-parser": "~1.2.1",
++ "statuses": "~2.0.2"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/send/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/serve-static": {
++ "version": "1.16.3",
++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
++ "license": "MIT",
++ "dependencies": {
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "parseurl": "~1.3.3",
++ "send": "~0.19.1"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "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/source-map": {
++ "version": "0.6.1",
++ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
++ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
++ "license": "BSD-3-Clause",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/source-map-support": {
++ "version": "0.5.21",
++ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
++ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer-from": "^1.0.0",
++ "source-map": "^0.6.0"
++ }
++ },
++ "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/strnum": {
++ "version": "2.5.1",
++ "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.5.1.tgz",
++ "integrity": "sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/NaturalIntelligence"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "anynum": "^1.0.1"
++ }
++ },
++ "node_modules/terser": {
++ "version": "5.51.2",
++ "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz",
++ "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "@jridgewell/source-map": "^0.3.3",
++ "acorn": "^8.15.0",
++ "commander": "^2.20.0",
++ "source-map-support": "~0.5.20"
++ },
++ "bin": {
++ "terser": "bin/terser"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "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/type-is": {
++ "version": "1.6.18",
++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
++ "license": "MIT",
++ "dependencies": {
++ "media-typer": "0.3.0",
++ "mime-types": "~2.1.24"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/uhyphen": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
++ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
++ "license": "ISC"
++ },
++ "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/utils-merge": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4.0"
++ }
++ },
++ "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/xml-naming": {
++ "version": "0.3.0",
++ "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
++ "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/NaturalIntelligence"
++ }
++ ],
++ "license": "MIT",
++ "engines": {
++ "node": ">=16.0.0"
++ }
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..8aca3c7
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,15 @@
++{
++ "name": "openvibe-directory",
++ "version": "0.1.0",
++ "private": true,
++ "description": "Source-linked directories that refresh themselves",
++ "main": "server/index.js",
++ "scripts": {"start": "node server/index.js", "test": "node --test test/*.test.js"},
++ "engines": {"node": ">=22"},
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "express": "^4.21.2",
++ "fast-xml-parser": "^5.2.5",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.202.0"
++ }
++}
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..eda17f2
---- /dev/null
++++ b/server/app.js
@@@ -0,0 +1,31 @@
++'use strict';
++const express=require('express');
++const path=require('node:path');
++const {createStore,kinds}=require('./sources');
++const v=require('./views');
++function createApp({store=createStore()}={}){
++ const app=express();app.disable('x-powered-by');
++ app.use((_q,r,next)=>{r.set('X-Content-Type-Options','nosniff');r.set('Referrer-Policy','strict-origin-when-cross-origin');next();});
++ app.use('/shared',require('openvibe-shared/serve').handler());
++ app.get('/api/health',(_q,r)=>r.json({status:'ok',service:'directory'}));
++ app.get('/api/ready',(_q,r)=>r.json({status:'ok',service:'directory'}));
++ app.get('/release.json',(_q,r)=>r.json(require('openvibe-shared/release').createRelease({service:'directory',root:path.join(__dirname,'..')})));
++ const all=()=>store.list();
++ const counts=()=>Object.fromEntries(Object.keys(kinds).map(k=>[k,store.list(k).length]));
++ const find=(kind,slug)=>store.list(kind).find(e=>e.slug===slug);
++ const search=(q,kind)=>{const terms=String(q||'').toLowerCase().trim().slice(0,120);const rows=kind?store.list(kind):all();return !terms?rows:rows.filter(e=>`${e.name} ${e.description} ${e.kind} ${e.group||''} ${e.provider||''} ${(e.topics||[]).join(' ')}`.toLowerCase().includes(terms));};
++ app.get('/api/v1/kinds',(_q,r)=>r.json({kinds:Object.entries(kinds).map(([id,name])=>({id,name,count:counts()[id]})),last_checked:store.state.refreshed_at,status:store.state.status}));
++ app.get('/api/v1/entries',(q,r)=>{const kind=String(q.query.kind||'');if(kind&&!kinds[kind])return r.status(400).json({error:'invalid_kind'});const page=Math.max(1,Math.min(1000,Number(q.query.page)||1)),limit=50;const rows=search(q.query.q,kind);r.json({count:rows.length,page,entries:rows.slice((page-1)*limit,page*limit)});});
++ app.get('/api/v1/entries/:kind/:slug',(q,r)=>{const e=find(q.params.kind,q.params.slug);return e?r.json(e):r.status(404).json({error:'entry.not_found'});});
++ app.get('/robots.txt',(_q,r)=>r.type('text').send('User-agent: *\nAllow: /\nSitemap: https://openvibe.directory/sitemap.xml\n'));
++ app.get('/llms.txt',(_q,r)=>r.type('text').send('# OpenVibe.Directory\n\nSource-linked listings with last checked dates.\n\n'+Object.entries(kinds).map(([id,name])=>`- [${name}](https://openvibe.directory/kinds/${id}): ${store.list(id).length} entries`).join('\n')+'\n\nJSON: https://openvibe.directory/api/v1/entries\n'));
++ app.get('/sitemap.xml',(_q,r)=>{const paths=['/',...Object.keys(kinds).map(k=>`/kinds/${k}`),...all().map(v.entryPath)];r.type('application/xml').send('<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'+paths.map(p=>`<url><loc>https://openvibe.directory${v.esc(p)}</loc></url>`).join('')+'</urlset>');});
++ app.get('/',(q,r)=>r.send(v.page('Directories',v.home(kinds,counts(),all()),'/',[{'@context':'https://schema.org','@type':'WebSite',name:'OpenVibe.Directory',url:'https://openvibe.directory/',potentialAction:{'@type':'SearchAction',target:'https://openvibe.directory/search?q={search_term_string}','query-input':'required name=search_term_string'}}])));
++ app.get('/search',(q,r)=>{const kind=String(q.query.kind||'');if(kind&&!kinds[kind])return r.sendStatus(404);const term=String(q.query.q||'').slice(0,120);const rows=search(term,kind).slice(0,200);r.send(v.page('Search directory',v.listing(kind||'all','Search directory',rows,term),`/search?q=${encodeURIComponent(term)}`));});
++ app.get('/kinds/:kind',(q,r)=>{const kind=q.params.kind;if(!kinds[kind])return r.sendStatus(404);r.send(v.page(kinds[kind],v.listing(kind,kinds[kind],store.list(kind)),q.path));});
++ app.get('/entries/:kind/:slug',(q,r)=>{const e=find(q.params.kind,q.params.slug);if(!e)return r.sendStatus(404);r.vary('Accept');if(q.accepts(['html','text/markdown'])==='text/markdown')return r.type('text/markdown').send(v.markdown(e));r.send(v.page(e.name,v.detail(e),q.path,[{'@context':'https://schema.org','@type':e.kind==='businesses'?'LocalBusiness':'Thing',name:e.name,description:e.description,url:`https://openvibe.directory${q.path}`,dateModified:e.last_checked,sameAs:e.sources.map(s=>s.url)}]));});
++ app.use((_q,r)=>r.status(404).send('Not found'));
++ store.start();
++ return app;
++}
++module.exports={createApp};
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..bb7b30d
---- /dev/null
++++ b/server/index.js
@@@ -0,0 +1,5 @@
++'use strict';
++const {createApp} = require('./app');
++const port = Number(process.env.PORT || 5281);
++const server=createApp().listen(port, '127.0.0.1', () => process.stdout.write(`directory listening on ${port}\n`));
++process.on('SIGTERM',()=>server.close(()=>process.exit(0)));
ddiff --git a/server/sources.js b/server/sources.js
nnew file mode 100644
iindex 0000000..d433abb
---- /dev/null
++++ b/server/sources.js
@@@ -0,0 +1,87 @@
++'use strict';
++const {XMLParser} = require('fast-xml-parser');
++const URLS = Object.freeze({
++ sites: 'https://openvibe.network/api/frame?host=openvibe.network',
++ tools: 'https://openvibe.tools/api/v1/tools',
++ models: 'https://openvibe.compare/api/v1/comparisons/ai-models',
++ projects: 'https://api.github.com/search/repositories?q=org%3AOpenVibers+archived%3Afalse&per_page=100',
++ businesses: 'https://api.openstreetmap.org/api/0.6/map?bbox=-122.425,37.774,-122.415,37.780'
++});
++const kinds = Object.freeze({sites:'OpenVibe sites',tools:'OpenVibe tools',models:'AI models',providers:'AI providers',projects:'Open-source projects',businesses:'Local businesses',people:'Opt-in people'});
++const iso = value => { const d = new Date(value); return Number.isFinite(d.getTime()) ? d.toISOString() : null; };
++const safeUrl = value => { try { const u = new URL(value); return u.protocol === 'https:' ? u.href : null; } catch { return null; } };
++const slug = value => String(value).toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'').slice(0,100);
++function entry(kind, id, name, description, website, source, checked, details={}) {
++ if (!id || !name || !safeUrl(source)) return null;
++ return {kind,id:String(id),slug:slug(id),name:String(name).slice(0,160),description:String(description||'').slice(0,500),website:safeUrl(website),sources:[{url:safeUrl(source),checked_at:checked}],last_checked:checked,...details};
++}
++function sites(data, checked) {
++ return [...(data.nav||[]),...(data.soon||[])].map(s=>entry('sites',s.id,s.name,s.tagline,s.url,s.url||URLS.sites,checked,{group:s.group||null})).filter(Boolean);
++}
++function tools(data, checked) {
++ return (data.tools||[]).map(t=>entry('tools',t.id,t.name,t.summary,t.docs||`https://openvibe.tools/tool/${encodeURIComponent(t.id)}`,URLS.tools,iso(data.updated_at)||checked,{group:t.family||null})).filter(Boolean);
++}
++function models(data, checked) {
++ const out=[], makers=new Map();
++ for (const m of data.things||[]) {
++ const id=m.id, maker=m.maker||String(id).split('/')[0];
++ const source=safeUrl(m.url)||URLS.models;
++ const e=entry('models',id,m.name,`AI model from ${maker}. See the dated comparison for prices and measured results.`,`https://openvibe.compare/things/${encodeURIComponent(id)}`,source,iso(data.updated_at)||checked,{provider:maker});
++ if(e) out.push(e);
++ if(maker && !makers.has(maker)) makers.set(maker,entry('providers',maker,maker,'AI model provider.',source,URLS.models,iso(data.updated_at)||checked));
++ }
++ return [...out,...[...makers.values()].filter(Boolean)];
++}
++function projects(data, checked) {
++ return (data.items||[]).map(p=>entry('projects',p.full_name,p.name,p.description,p.html_url,p.html_url,iso(p.updated_at)||checked,{group:p.topics?.[0]||'other',topics:(p.topics||[]).slice(0,12)})).filter(Boolean);
++}
++function businesses(xml, checked) {
++ const doc=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false}).parse(xml);
++ const nodes=[...toArray(doc.osm?.node),...toArray(doc.osm?.way),...toArray(doc.osm?.relation)];
++ const out=[];
++ for(const n of nodes){
++ const tags=Object.fromEntries(toArray(n.tag).filter(t=>t.k && t.v).map(t=>[t.k,String(t.v)]));
++ if(!tags.name || !(tags.shop || ['restaurant','cafe','bar','pub','pharmacy','bank','clinic','fast_food'].includes(tags.amenity))) continue;
++ const type=n.nd?'way':n.member?'relation':'node';
++ const id=`${type}-${n.id}`;
++ const source=`https://www.openstreetmap.org/${type}/${n.id}`;
++ const website=safeUrl(tags.website)||safeUrl(tags['contact:website'])||source;
++ const phone=tags['contact:phone']||tags.phone||null;
++ out.push(entry('businesses',id,tags.name,`${tags.shop||tags.amenity} in San Francisco, mapped in OpenStreetMap.`,website,source,checked,{group:tags.shop||tags.amenity,phone:phone&&String(phone).slice(0,80),osm_attribution:'© OpenStreetMap contributors, ODbL 1.0'}));
++ }
++ return out.filter(Boolean);
++}
++function toArray(value){return value==null?[]:Array.isArray(value)?value:[value];}
++const parsers={sites,tools,models,projects,businesses};
++function createStore({fetchImpl=fetch,now=()=>new Date(),intervalMs=6*60*60*1000}={}){
++ const state={entries:new Map(Object.keys(kinds).map(k=>[k,[]])),status:{},refreshed_at:null};
++ let pending=null, timer=null;
++ async function refresh(){
++ if(pending)return pending;
++ pending=(async()=>{
++ const checked=now().toISOString();
++ await Promise.all(Object.entries(parsers).map(async([kind,parse])=>{
++ try{
++ const r=await fetchImpl(URLS[kind],{headers:{'Accept':kind==='businesses'?'application/xml':'application/json','User-Agent':'OpenVibe.Directory/0.1 (+https://openvibe.directory)'},signal:AbortSignal.timeout(12000)});
++ if(!r.ok)throw Error(`upstream ${r.status}`);
++ const data=kind==='businesses'?await r.text():await r.json();
++ const entries=parse(data,checked);
++ if(!entries.length)throw Error('source returned no usable entries');
++ if(kind==='models'){
++ state.entries.set('models',entries.filter(e=>e.kind==='models'));
++ state.entries.set('providers',entries.filter(e=>e.kind==='providers'));
++ } else state.entries.set(kind,entries);
++ state.status[kind]={ok:true,last_checked:checked,count:entries.length};
++ }catch(err){state.status[kind]={ok:false,last_checked:checked,count:state.entries.get(kind).length,error:String(err.message).slice(0,100)};}
++ }));
++ state.refreshed_at=checked;
++ return state;
++ })().finally(()=>{pending=null;});
++ return pending;
++ }
++ function start(){if(!timer){void refresh();timer=setInterval(()=>{void refresh();},intervalMs);timer.unref?.();}}
++ function stop(){if(timer)clearInterval(timer);timer=null;}
++ function list(kind){return kind?state.entries.get(kind)||[]:[...state.entries.values()].flat();}
++ return {state,refresh,start,stop,list};
++}
++module.exports={URLS,kinds,sites,tools,models,projects,businesses,createStore};
ddiff --git a/server/views.js b/server/views.js
nnew file mode 100644
iindex 0000000..b54fcfd
---- /dev/null
++++ b/server/views.js
@@@ -0,0 +1,14 @@
++'use strict';
++const kit=require('openvibe-shared/kit');
++const shell=require('openvibe-shared/shell');
++const serve=require('openvibe-shared/serve');
++const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
++const json=v=>JSON.stringify(v).replace(/</g,'\\u003c');
++const entryPath=e=>`/entries/${encodeURIComponent(e.kind)}/${encodeURIComponent(e.slug)}`;
++function page(title,body,path='/',ld=[]){return shell.page({name:'OpenVibe.Directory',siteName:'OpenVibe.Directory',title,description:'Source-linked directories that keep themselves current.',canonical:`https://openvibe.directory${path}`,robots:'index, follow',jsonLd:ld,navbar:{service:'directory',apiBase:'https://openvibe.network',links:[{label:'Directories',href:'/'}]},footer:{service:'directory',variant:'full',mount:'#ov-footer'},head:`<link rel="stylesheet" href="${serve.url('kit.css')}">`,body:`<div id="navbar-mount"></div><main id="main" class="ovk-container">${body}</main>`});}
++function card(e){return `<article class="ovk-card"><h2><a href="${entryPath(e)}">${esc(e.name)}</a></h2><p>${esc(e.description)}</p><p><small>Checked <time datetime="${esc(e.last_checked)}">${esc(e.last_checked.slice(0,10))}</time></small></p></article>`;}
++function home(kinds,counts,entries,q=''){return `${kit.head({title:'Directory',sub:'Listings with sources and a last checked date.',icon:'directory',sticky:false})}<form action="/search"><label>Search <input type="search" name="q" value="${esc(q)}"></label> <button class="ovk-btn ovk-btn--primary">Search</button></form><ul>${Object.entries(kinds).map(([id,name])=>`<li><a href="/kinds/${id}">${esc(name)}</a> (${counts[id]||0})</li>`).join('')}</ul><h2>Recently checked</h2>${entries.slice(0,20).map(card).join('')}`;}
++function listing(kind,name,entries,q=''){return `${kit.head({title:name,sub:`${entries.length} listings`,icon:'directory',sticky:false})}<form action="/search"><label>Search <input type="search" name="q" value="${esc(q)}"></label><input type="hidden" name="kind" value="${esc(kind)}"> <button class="ovk-btn ovk-btn--quiet">Search</button></form>${entries.length?entries.map(card).join(''):'<p>No listings available yet. The source will be checked again on the next refresh.</p>'}`;}
++function detail(e){return `${kit.head({title:e.name,sub:e.description,icon:'directory',sticky:false})}<p>Directory: <a href="/kinds/${esc(e.kind)}">${esc(e.kind)}</a></p><p>Last checked: <time datetime="${esc(e.last_checked)}">${esc(e.last_checked)}</time></p>${e.website?`<p><a class="ovk-btn ovk-btn--primary" href="${esc(e.website)}">Visit website</a></p>`:''}${e.phone?`<p>Business-published phone: ${esc(e.phone)}</p>`:''}<h2>Sources</h2><ul>${e.sources.map(s=>`<li><a href="${esc(s.url)}">${esc(s.url)}</a> · checked ${esc(s.checked_at)}</li>`).join('')}</ul>${e.osm_attribution?`<p>${esc(e.osm_attribution)} · <a href="https://www.openstreetmap.org/copyright">Licence and attribution</a></p>`:''}<p><a href="/api/v1/entries/${esc(e.kind)}/${encodeURIComponent(e.slug)}">JSON data</a></p>`;}
++function markdown(e){return `# ${e.name}\n\n${e.description}\n\nKind: ${e.kind}\nLast checked: ${e.last_checked}\n${e.website?`Website: ${e.website}\n`:''}${e.phone?`Business-published phone: ${e.phone}\n`:''}\nSources:\n${e.sources.map(s=>`- ${s.url} (checked ${s.checked_at})`).join('\n')}\n`;}
++module.exports={page,card,home,listing,detail,markdown,esc,json,entryPath};
ddiff --git a/test/directory.test.js b/test/directory.test.js
nnew file mode 100644
iindex 0000000..69a84fa
---- /dev/null
++++ b/test/directory.test.js
@@@ -0,0 +1,21 @@
++'use strict';
++const {test}=require('node:test');
++const assert=require('node:assert/strict');
++const {createStore,businesses}=require('../server/sources');
++const {createApp}=require('../server/app');
++test('business phone is only read from business OSM tags and source is attributed',()=>{
++ const xml='<osm><node id="1"><tag k="shop" v="books"/><tag k="name" v="Bookshop"/><tag k="phone" v="+1 415 555 0100"/></node><node id="2"><tag k="name" v="Jane Smith"/><tag k="phone" v="+1 415 555 0101"/></node></osm>';
++ const rows=businesses(xml,'2026-11-10T00:00:00.000Z');
++ assert.equal(rows.length,1);assert.equal(rows[0].phone,'+1 415 555 0100');assert.match(rows[0].sources[0].url,/openstreetmap/);assert.match(rows[0].osm_attribution,/ODbL/);
++});
++test('failed refresh retains the last successful source and reports stale status',async()=>{
++ let fail=false;const fetchImpl=async(url)=>{if(fail)throw Error('source down');return {ok:true,json:async()=>url.includes('frame?')?{nav:[{id:'live',name:'Live',url:'https://openvibe.live/'}]}:{tools:[{id:'yaml',name:'YAML',docs:'https://openvibe.tools/tool/yaml'}],things:[{id:'a/b',name:'B',maker:'A',url:'https://example.com'}],items:[{full_name:'OpenVibers/Test',name:'Test',html_url:'https://github.com/OpenVibers/Test'}]},text:async()=>'<osm><node id="1"><tag k="shop" v="books"/><tag k="name" v="Books"/></node></osm>'};};
++ const store=createStore({fetchImpl});await store.refresh();const before=store.list('sites');fail=true;await store.refresh();assert.deepEqual(store.list('sites'),before);assert.equal(store.state.status.sites.ok,false);
++});
++test('entry page emits structured data and escapes source text',async()=>{
++ const store=createStore({fetchImpl:async()=>({ok:false,status:503})});store.start=()=>{};
++ store.state.entries.set('sites',[{kind:'sites',id:'x',slug:'x',name:'<bad>',description:'Test',website:'https://example.com/',last_checked:'2026-11-10T00:00:00.000Z',sources:[{url:'https://example.com/',checked_at:'2026-11-10T00:00:00.000Z'}]}]);
++ const server=createApp({store}).listen(0,'127.0.0.1');
++ await new Promise(resolve=>server.once('listening',resolve));
++ try{const r=await fetch(`http://127.0.0.1:${server.address().port}/entries/sites/x`);const html=await r.text();assert.equal(r.status,200);assert.match(html,/schema.org/);assert.ok(!html.includes('<bad>'));}finally{server.close();}
++});