openvibers/openvibe.computer Commits
Build OpenVibe Computer front door (#1)
Changes
nnew file mode 100644@@@ -0,0 +1,11 @@++PORT=5135++BASE_URL=http://localhost:5135++OV_NETWORK_URL=https://openvibe.network++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000++OV_OAUTH_CLIENT_ID=computer++OV_OAUTH_CLIENT_SECRET=++OV_RUN_URL=https://openvibe.run++# Empty means the owner has not set the allowance or price yet.++COMPUTER_BROWSER_FREE_MINUTES=++COMPUTER_DESKTOP_FREE_MINUTES=++COMPUTER_PRICE_PER_MINUTE=nnew file mode 100644@@@ -0,0 +1,23 @@++name: ci++on:++ push:++ branches: [main]++ pull_request:++ workflow_dispatch:++permissions:++ contents: read++jobs:++ test:++ runs-on: ubuntu-24.04++ steps:++ - uses: actions/checkout@v4++ - uses: actions/setup-node@v4++ with:++ node-version: 22.22.1++ cache: npm++ - run: npm ci --no-audit --no-fund++ - name: syntax++ run: find server test -name '*.js' -print0 | xargs -0 -n1 node --check++ - run: npm test++ security:++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176nnew file mode 100644@@@ -0,0 +1,8 @@++node_modules/++.env++.env.*++!.env.example++*.log++data/++dist/++@@@ -1,2 +1,23 @@# OpenVibe.Computer--The front door and API facade for OpenVibe computer sessions++++The front door and API facade for OpenVibe computers. Run owns sessions, placement and budgets; this service renders the product and forwards a person's Network token to Run unchanged.++++## Run locally++++Node 22 or newer is required.++++```sh++npm ci++cp .env.example .env++npm run dev++```++++The site listens on `127.0.0.1:5135`. Configure `OV_OAUTH_CLIENT_SECRET` for Network sign-in and `OV_RUN_URL` for Run. Allowance and per-minute settings are intentionally unset until the owner chooses them.++++## Run support today++++The current Run session API supports browser session create/list/read/end and one-off screen, input and browser commands. Placement and budget enforcement remain in Run. Run does not yet expose live session streams, B3 control modes, control handoff, action timelines, snapshots, non-browser computer kinds or templates. The UI marks those capabilities as pending.++++## Launch notes++++See [deploy/LAUNCH.md](deploy/LAUNCH.md) for the DNS, Cloudflare, OAuth and vhost sequence. The host-level parked-redirect swap is intentionally a post-review action.nnew file mode 100644@@@ -0,0 +1,12 @@++# Launch sequence for openvibe.computer++++This PR prepares the service and host configuration. Opus performs the host-level swap after review; do not remove the parked redirect before the Computer vhost and service are ready.++++1. **DNS and Cloudflare:** make sure the `openvibe.computer` zone is active at Cloudflare. Add apex and `www` A/AAAA records to the OpenVibe host, proxied through Cloudflare like the other room domains. Keep the DNS-only ACME validation route reachable during certificate issuance. Confirm `openvibe.computer` and `www.openvibe.computer` resolve to the intended host.++2. **TLS:** issue `/etc/letsencrypt/live/openvibe.computer/{fullchain.pem,privkey.pem}` for apex and `www` before enabling the TLS server block.++3. **Network OAuth:** deploy the Network PR with the Computer cutover. It seeds confidential OAuth client `computer`, redirect URI `https://openvibe.computer/auth/callback`, and lists Computer in the Network Frame. The same host change installs Computer's vhost before removing the old redirect.++4. **Service:** after the OpenVibe.Host inventory PR is available, run `sudo ovhost service add computer`. It adds the service entry, clones the repo, creates `/etc/openvibe/computer.env` with mode 0600 and prints the remaining setup steps without printing secrets. Use Network's service-principal setup to create or rotate client `computer`; set `OV_RUN_URL=https://openvibe.run` and `OV_OAUTH_CLIENT_ID=computer`, then run `sudo ovhost validate computer` and `sudo ovhost deploy computer --restart`. Check `http://127.0.0.1:5135/api/health`.++5. **Vhost and parked redirect:** after review and successful readiness checks, Opus removes the `openvibe.computer` redirect blocks from Network's `parked-domains.conf`, installs `deploy/nginx/openvibe.computer.conf` with `sudo ovhost nginx render computer --install`, runs `nginx -t`, and reloads nginx as one host-level swap. The Host inventory keeps `repoVhost` review-only so a normal Computer deploy does not install the vhost early.++6. **Verify:** check `/`, `/api/health`, and `/auth/login` through HTTPS. The vhost leaves `/api/` public to the service; user endpoints require a Network token and Run authorizes the forwarded token.++++No host change is part of this PR.nnew file mode 100644@@@ -0,0 +1,54 @@++## openvibe.computer — reference vhost for ov-vhost-install.++## Computer answers pages and /api/ on 127.0.0.1:5135. The facade forwards the caller's token to Run server-side.++limit_req_zone $binary_remote_addr zone=computer_auth:10m rate=10r/m;++limit_req_zone $binary_remote_addr zone=computer_api:10m rate=5r/s;++++server {++ listen 80;++ listen [::]:80;++ server_name openvibe.computer www.openvibe.computer;++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://openvibe.computer$request_uri; }++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name www.openvibe.computer;++ ssl_certificate /etc/letsencrypt/live/openvibe.computer/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.computer/privkey.pem;++ return 301 https://openvibe.computer$request_uri;++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name openvibe.computer;++ ssl_certificate /etc/letsencrypt/live/openvibe.computer/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.computer/privkey.pem;++ access_log /var/log/nginx/openvibe.computer.access.log;++ error_log /var/log/nginx/openvibe.computer.error.log;++ client_max_body_size 128k;++ add_header X-Content-Type-Options nosniff always;++ add_header Referrer-Policy strict-origin-when-cross-origin always;++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;++ gzip on;++ gzip_proxied any;++ gzip_vary on;++ gzip_comp_level 5;++ gzip_min_length 1024;++ gzip_types text/css application/javascript application/json application/problem+json text/plain;++ 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 X-Forwarded-Proto $scheme;++ location = /auth/me { limit_req zone=computer_api burst=30 nodelay; limit_req_status 429; proxy_pass http://127.0.0.1:5135; }++ location /auth/ { limit_req zone=computer_auth burst=10 nodelay; limit_req_status 429; proxy_pass http://127.0.0.1:5135; }++ location /api/ { limit_req zone=computer_api burst=20 nodelay; limit_req_status 429; proxy_pass http://127.0.0.1:5135; }++ location ~* ^/(metrics|internal)(/|$) { return 404; }++ location ~ /\.(?!well-known) { deny all; }++ location / { proxy_pass http://127.0.0.1:5135; }++}nnew file mode 100644@@@ -0,0 +1,27 @@++[Unit]++Description=OpenVibe.Computer — the front door to your computers.++After=network.target++Wants=openvibe-network.service++++[Service]++Type=simple++User=ubuntu++Group=ubuntu++WorkingDirectory=/opt/openvibe.computer++EnvironmentFile=/etc/openvibe/computer.env++Environment=NODE_ENV=production++Environment=PORT=5135++Environment=HOST=127.0.0.1++Environment=PATH=/usr/local/bin:/usr/bin:/bin++ExecStart=/usr/bin/env node /opt/openvibe.computer/server/index.js++Restart=always++RestartSec=5++KillSignal=SIGTERM++TimeoutStopSec=15++NoNewPrivileges=true++PrivateTmp=true++ProtectSystem=strict++ProtectHome=true++++[Install]++WantedBy=multi-user.targetnnew file mode 100644@@@ -0,0 +1,1416 @@++{++ "name": "openvibe-computer",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-computer",++ "version": "0.1.0",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "cookie-parser": "^1.4.7",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "helmet": "^8.1.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.346.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.1",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0"++ },++ "engines": {++ "node": ">=22.0.0"++ }++ },++ "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/ajv": {++ "version": "8.20.0",++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",++ "license": "MIT",++ "dependencies": {++ "fast-deep-equal": "^3.1.3",++ "fast-uri": "^3.0.1",++ "json-schema-traverse": "^1.0.0",++ "require-from-string": "^2.0.2"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/epoberezkin"++ }++ },++ "node_modules/ajv-formats": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",++ "license": "MIT",++ "dependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependenciesMeta": {++ "ajv": {++ "optional": true++ }++ }++ },++ "node_modules/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/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": "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-parser": {++ "version": "1.4.7",++ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",++ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",++ "license": "MIT",++ "dependencies": {++ "cookie": "0.7.2",++ "cookie-signature": "1.0.6"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/cookie-signature": {++ "version": "1.0.6",++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",++ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",++ "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/dotenv": {++ "version": "16.6.1",++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",++ "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/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-deep-equal": {++ "version": "3.1.3",++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",++ "license": "MIT"++ },++ "node_modules/fast-uri": {++ "version": "3.1.9",++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",++ "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fastify"++ },++ {++ "type": "opencollective",++ "url": "https://opencollective.com/fastify"++ }++ ],++ "license": "BSD-3-Clause"++ },++ "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/helmet": {++ "version": "8.3.0",++ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",++ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",++ "license": "MIT",++ "engines": {++ "node": ">=18.0.0"++ },++ "funding": {++ "url": "https://github.com/sponsors/EvanHahn"++ }++ },++ "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/json-schema-traverse": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",++ "license": "MIT"++ },++ "node_modules/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-contracts": {++ "version": "0.346.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.346.0",++ "integrity": "sha512-ncmROx6i7VQa6l1FitWZhPArFEYskrIbUWTdMHKty7c5m6OIYkjh8GRm9HxzfwH9vD89isTzQiX16yfU5NYHhw==",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "ajv": "^8.17.1",++ "ajv-formats": "^3.0.1"++ },++ "bin": {++ "openvibe-contracts-check": "bin/check-service.js"++ },++ "engines": {++ "node": ">=20"++ }++ },++ "node_modules/openvibe-sdk": {++ "version": "0.97.1",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.1",++ "integrity": "sha512-zCRmu7NoOi7kI0v04N8uypCgsuEcf9kCzkw88r0+dj9PG3AA/z+Pp4BZaX6Z1JUE3sdrtc17D5LcD05IbWsw8w==",++ "license": "MIT",++ "bin": {++ "openvibe-download": "bin/openvibe-download.js"++ },++ "engines": {++ "node": ">=20"++ },++ "peerDependencies": {++ "@electric-sql/pglite": "^0.5.8",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0"++ },++ "peerDependenciesMeta": {++ "@electric-sql/pglite": {++ "optional": true++ },++ "iovalkey": {++ "optional": true++ },++ "pg": {++ "optional": true++ }++ }++ },++ "node_modules/openvibe-shared": {++ "version": "4.135.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0",++ "integrity": "sha512-+v59lQjCjkb8poKNyKOKfTr8SoFMyr/4dKMoIpPgxqvn8MDFYpylvI80U3AvSxeJTqZpthhrBBdkgVlok/M3dg==",++ "license": "MIT",++ "dependencies": {++ "jsonwebtoken": "^9.0.0",++ "linkedom": "^0.18.13"++ },++ "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-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/require-from-string": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/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/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/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"++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "name": "openvibe-computer",++ "version": "0.1.0",++ "private": true,++ "description": "The front door and API facade for OpenVibe computer sessions",++ "main": "server/index.js",++ "scripts": { "start": "node server/index.js", "dev": "NODE_ENV=development node server/index.js", "test": "node --test test/*.test.js" },++ "engines": { "node": ">=22.0.0" },++ "dependencies": {++ "cookie-parser": "^1.4.7",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "helmet": "^8.1.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.346.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.1",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0"++ },++ "license": "AGPL-3.0-only"++}nnew file mode 100644@@@ -0,0 +1,10 @@++{++ "name": "OpenVibe.Computer",++ "description": "Start and manage your OpenVibe computer sessions. Run owns placement, budgets and session state.",++ "url": "https://openvibe.computer",++ "version": "1.0.0",++ "documentation": "https://openvibe.computer/docs",++ "openapi": "https://openvibe.computer/openapi.json",++ "authentication": { "type": "bearer", "description": "A person's OpenVibe Network token; forwarded to Run." },++ "capabilities": ["computers.list", "computers.create", "computers.read", "computers.stop", "computers.screenshot", "computers.act", "computers.handoff"]++}nnew file mode 100644@@@ -0,0 +1,20 @@++:root{--ink:#20222a;--muted:#667085;--paper:#f7f6f2;--panel:#fff;--line:#e2e2e6;--accent:#5846e8;--soft:#efedff;--good:#147d58}html{background:var(--paper);color:var(--ink);font-family:Inter,ui-sans-serif,system-ui,sans-serif}body{margin:0}.computer-page{box-sizing:border-box;max-width:1180px;margin:0 auto;padding:18px 24px 80px}.account-line{display:flex;justify-content:flex-end;min-height:24px;color:var(--muted);font-size:13px}.computer-page a{color:var(--accent);text-underline-offset:3px}.hero{display:grid;grid-template-columns:minmax(0,1.08fr) minmax(260px,.92fr);gap:40px;align-items:center;padding:68px 0 80px}.hero h1,.page-intro h1{font-size:clamp(2.3rem,5vw,4rem);line-height:1.03;letter-spacing:-.055em;max-width:720px;margin:12px 0 20px}.hero p,.page-intro p{max-width:660px;color:var(--muted);font-size:1.08rem;line-height:1.65}.eyebrow{font-size:.72rem;font-weight:800;letter-spacing:.12em;color:var(--accent)}.button-primary,.button-secondary,.button-danger,.button-quiet{display:inline-flex;min-height:44px;align-items:center;justify-content:center;box-sizing:border-box;padding:0 18px;border:1px solid transparent;border-radius:12px;font-weight:700;text-decoration:none;cursor:pointer}.button-primary{background:var(--accent);color:white!important}.button-secondary{background:var(--panel);border-color:var(--line)}.button-danger{background:#a52f36;color:white}.button-quiet{background:var(--panel);border-color:var(--line);min-height:40px}.button-primary:disabled,.button-danger:disabled{opacity:.55;cursor:not-allowed}.hero-art{min-height:250px;display:grid;place-content:center;filter:drop-shadow(0 26px 34px #24205224)}.screen{width:min(390px,75vw);height:220px;border:11px solid #25263a;border-radius:22px;background:linear-gradient(140deg,#c9c1ff,#f5e9db 52%,#a6e1ce);overflow:hidden}.screen-top{height:25px;background:#292a40}.screen-content{padding:45px 34px;display:grid;gap:12px}.screen-content i{height:14px;border-radius:20px;background:#fff9}.screen-content i:nth-child(2){width:70%}.screen-content i:nth-child(3){width:45%}.screen-base{height:14px;width:110%;margin-left:-5%;border-radius:0 0 22px 22px;background:#45465d}.section-heading{display:flex;justify-content:space-between;align-items:end;gap:24px;margin-bottom:22px}.section-heading h2,.mode-panel h2,.timeline h2{margin:8px 0 0;letter-spacing:-.03em;font-size:1.8rem}.section-heading p{max-width:390px;color:var(--muted);line-height:1.55}.kind-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.kind-card,.template-card,.price-grid article,.sign-in-callout,.timeline,.mode-panel{background:var(--panel);border:1px solid var(--line);border-radius:18px;padding:22px;min-width:0}.kind-card h3,.template-card h2{margin:9px 0;font-size:1.25rem}.kind-card p,.template-card p{color:var(--muted);line-height:1.5}.kind-card .runs{font-size:.9rem}.kind-card .price{min-height:54px;font-size:.84rem;color:var(--ink)}.availability{display:inline-flex;padding:10px 0;color:var(--muted);font-size:.86rem}.your-computers,.sign-in-callout,#start{margin-top:74px}.computer-list{list-style:none;margin:0;padding:0;border-top:1px solid var(--line)}.computer-list li{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px 16px;align-items:center;padding:16px 4px;border-bottom:1px solid var(--line)}.computer-list li>span,.computer-list li>form{color:var(--muted);font-size:.88rem}.computer-list li>form{grid-column:2;grid-row:1/3}.status{color:var(--good)!important}.page-intro{padding:50px 0 34px}.page-intro h1{font-size:clamp(2.2rem,4vw,3.4rem)}.template-grid,.price-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.template-mark{display:grid;place-items:center;width:38px;height:38px;border-radius:12px;background:var(--soft);color:var(--accent);font-weight:800}.price-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.price-grid h2{font-size:1.15rem}.price-grid p{line-height:1.55;color:var(--muted)}.fine-print{font-size:.86rem;line-height:1.5;color:var(--muted)}pre{white-space:pre-wrap;overflow-wrap:anywhere;padding:20px;background:#232438;color:#fff;border-radius:16px}.title-row{display:flex;justify-content:space-between;gap:16px;align-items:center}.screen-panel{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(290px,.7fr);gap:16px}.screen-placeholder{min-height:400px;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;border:1px dashed #b8b4d6;border-radius:18px;background:linear-gradient(140deg,#e7e4fb,#eff7f3);padding:26px}.screen-placeholder h2{margin:18px 0 6px}.screen-placeholder p{max-width:410px;color:var(--muted);line-height:1.5}.screen-led{width:12px;height:12px;border-radius:50%;background:#b98b27;box-shadow:0 0 0 7px #b98b271a}.mode-panel{display:flex;flex-direction:column}.mode-list{list-style:none;margin:16px 0;padding:0}.mode-list li{display:flex;justify-content:space-between;gap:8px;padding:12px 10px;border-radius:10px;border-bottom:1px solid var(--line);font-size:.88rem;line-height:1.35}.mode-list li.selected{background:var(--soft);color:var(--accent);font-weight:700}.mode-list li span{font-size:.72rem;color:var(--muted);white-space:nowrap}.timeline{margin-top:18px}.timeline p{color:var(--muted)}.sign-in-callout{padding:32px}@media(prefers-color-scheme:dark){:root{--ink:#f0eff6;--muted:#aaaaba;--paper:#11121a;--panel:#1b1c27;--line:#353642;--accent:#a89cff;--soft:#302b4a;--good:#79d4ac}.hero-art{filter:drop-shadow(0 26px 34px #0008)}.screen{border-color:#070812}.screen-top{background:#10101c}.screen-base{background:#262638}.screen-placeholder{background:linear-gradient(140deg,#29243e,#1d302d);border-color:#50496b}.button-secondary,.button-quiet{color:var(--ink)}}@media(max-width:760px){.computer-page{padding:12px 16px 56px}.hero{grid-template-columns:1fr;padding:44px 0 54px;gap:22px}.hero-art{min-height:180px;justify-self:center}.screen{height:160px;width:min(330px,76vw)}.kind-grid,.template-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.section-heading{display:block}.section-heading p{margin-bottom:0}.screen-panel{grid-template-columns:1fr}.screen-placeholder{min-height:260px}.title-row{align-items:flex-start;flex-direction:column}.price-grid{grid-template-columns:1fr}}@media(max-width:480px){.kind-grid,.template-grid{grid-template-columns:1fr}.computer-list li{grid-template-columns:minmax(0,1fr) auto}.computer-list li>span:nth-of-type(2){grid-column:1;overflow-wrap:anywhere}.computer-list li>form{grid-column:2;grid-row:1/4}.account-line{justify-content:flex-start}.button-primary,.button-secondary,.button-danger{max-width:100%}}++++/* Theme tokens (Shared presets: a person's theme and light/dark follow), over the first palette above. */++:root{--ink:var(--text-primary,#e6edf7);--muted:var(--text-secondary,#96a7c2);--paper:var(--bg-primary,#0a0f1c);--panel:var(--bg-card,#131c2e);--line:var(--border,#26344d);--accent:var(--accent-light,#60a5fa);--soft:color-mix(in srgb,var(--accent,#3b82f6) 12%,transparent);--good:var(--success,#22c55e)}++html{background:var(--paper);color:var(--ink)}++/* The hero is words and two buttons now (the drawn screen was a picture of nothing) */++.hero{grid-template-columns:1fr;max-width:760px}++/* One price sentence for the whole page */++.price-line{margin:-4px 0 16px;color:var(--muted);font-size:15px;line-height:1.5;max-width:70ch}++/* The one kind you can start today, then the six that are coming, compact */++.kind-grid:has(.kind-card--ready:only-child){grid-template-columns:minmax(0,560px)}++.soon-h{margin:28px 0 10px;font-size:15px;font-weight:750;color:var(--ink)}++.soon-h .muted{color:var(--muted);font-weight:600}++.kind-soon-grid{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))}++.kind-soon{display:grid;gap:3px;padding:12px 14px;border:1px dashed var(--line);border-radius:14px;background:color-mix(in srgb,var(--panel) 60%,transparent)}++.kind-soon b{font-size:15px}++.kind-soon span{font-size:13.5px;line-height:1.4;color:var(--muted)}++.kind-soon small{font-size:12px;color:var(--muted);opacity:.85}++@media (max-width:520px){.kind-soon-grid{grid-template-columns:1fr 1fr;gap:8px}.kind-soon{padding:10px 12px}}++button[disabled][title]{cursor:not-allowed;opacity:.6}nnew file mode 100644@@@ -0,0 +1,12 @@++# OpenVibe.Computer++++A computer for you, your AI, or both. OpenVibe.Computer is the front door and API facade; OpenVibe.Run owns sessions, budgets and placement.++++- [Home](/)++- [Templates](/templates)++- [Pricing](/pricing)++- [Docs](/docs)++- [Updates](/updates)++- [OpenAPI](/openapi.json)++- [Agent descriptor](/.well-known/agent.json)++nnew file mode 100644@@@ -0,0 +1,18 @@++{++ "openapi": "3.1.0",++ "info": { "title": "OpenVibe.Computer API", "version": "1.0.0", "description": "Thin facade for OpenVibe.Run sessions. The caller's bearer token is forwarded unchanged; Run enforces authorization, placement and budgets." },++ "servers": [{ "url": "https://openvibe.computer/api/v1" }],++ "security": [{ "bearerAuth": [] }],++ "components": { "securitySchemes": { "bearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "OpenVibe Network user token" } }, "schemas": { "ComputerCreate": { "type": "object", "properties": { "kind": { "const": "browser" }, "width": { "type": "integer" }, "height": { "type": "integer" }, "ttl_s": { "type": "integer" }, "idle_s": { "type": "integer" }, "machine": { "type": "string" }, "requirements": { "type": "object" }, "idempotency_key": { "type": "string" } }, "required": ["kind"], "additionalProperties": false }, "Act": { "type": "object", "properties": { "kind": { "enum": ["screen", "input", "browser"] }, "value": { "type": "object" }, "id": { "type": "string" } }, "required": ["kind", "value"], "additionalProperties": false } } },++ "paths": {++ "/computers": {++ "get": { "summary": "List your computers", "parameters": [{ "in": "query", "name": "state", "schema": { "enum": ["running", "ended", "all"] } }, { "in": "query", "name": "limit", "schema": { "type": "integer" } }], "responses": { "200": { "description": "Run session list" } } },++ "post": { "summary": "Start a browser computer", "description": "Maps to Run POST /api/v1/sessions. Run currently accepts browser sessions only.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ComputerCreate" } } } }, "responses": { "201": { "description": "Run session" }, "422": { "description": "Unsupported kind or invalid request" } } }++ },++ "/computers/{id}": { "parameters": [{ "in": "path", "name": "id", "required": true, "schema": { "type": "string" } }], "get": { "summary": "Get a computer", "responses": { "200": { "description": "Run session" } } } },++ "/computers/{id}/stop": { "parameters": [{ "in": "path", "name": "id", "required": true, "schema": { "type": "string" } }], "post": { "summary": "Stop a computer", "responses": { "200": { "description": "Ended Run session" } } } },++ "/computers/{id}/screenshot": { "parameters": [{ "in": "path", "name": "id", "required": true, "schema": { "type": "string" } }], "post": { "summary": "Capture a screen", "description": "Sends a Run screen command. This is a one off image command, not a live stream.", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { "max_width": { "type": "integer", "minimum": 320, "maximum": 1920 } } } } } }, "responses": { "200": { "description": "Run screen command result" } } } },++ "/computers/{id}/act": { "parameters": [{ "in": "path", "name": "id", "required": true, "schema": { "type": "string" } }], "post": { "summary": "Send a computer command", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Act" } } } }, "responses": { "200": { "description": "Run command result" } } } },++ "/computers/{id}/handoff": { "parameters": [{ "in": "path", "name": "id", "required": true, "schema": { "type": "string" } }], "post": { "summary": "Hand controls to the person", "description": "Reserved facade operation. Returns 501 until Run supports B3 control modes and handoff.", "responses": { "501": { "description": "Run handoff is not available yet" } } } }++ }++}nnew file mode 100644@@@ -0,0 +1,45 @@++'use strict';++const express = require('express');++++function createApi({ run, sso }) {++ const r = express.Router();++ r.use(express.json({ limit: '64kb' }));++ const token = (req, res) => {++ const value = sso.extractToken(req);++ if (!value) { res.status(401).type('application/problem+json').json({ type: 'about:blank', title: 'Sign in required', status: 401, code: 'computer.auth.required' }); return null; }++ return value;++ };++ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch((e) => {++ if (res.headersSent) return next(e);++ const status = Number(e.status) || 502;++ res.status(status).type('application/problem+json').json({ type: 'about:blank', title: e.message || 'Run request failed', status, code: e.code || 'computer.run_error' });++ });++ r.get('/computers', wrap(async (req, res) => { const t = token(req, res); if (!t) return; res.json(await run.list(t, `?state=${encodeURIComponent(req.query.state || 'all')}&limit=${encodeURIComponent(req.query.limit || '20')}`)); }));++ r.post('/computers', wrap(async (req, res) => {++ const t = token(req, res); if (!t) return;++ if (req.body?.kind && req.body.kind !== 'browser') return res.status(422).json({ code: 'computer.kind.unsupported', detail: 'Run currently creates browser sessions only.' });++ const { template, kind, ...runBody } = req.body || {};++ if (template) return res.status(501).json({ code: 'computer.template.pending', detail: 'Run does not accept templates yet.' });++ res.status(201).json(await run.create(t, { kind: 'browser', ...runBody }));++ }));++ r.get('/computers/:id', wrap(async (req, res) => { const t = token(req, res); if (!t) return; res.json(await run.get(t, req.params.id)); }));++ r.post('/computers/:id/stop', wrap(async (req, res) => { const t = token(req, res); if (!t) return; res.json(await run.stop(t, req.params.id)); }));++ r.post('/computers/:id/screenshot', wrap(async (req, res) => {++ const t = token(req, res); if (!t) return;++ const width = Math.min(1920, Math.max(320, Number(req.body?.max_width) || 1280));++ res.json(await run.command(t, req.params.id, { kind: 'screen', value: { max_width: width } }));++ }));++ r.post('/computers/:id/act', wrap(async (req, res) => {++ const t = token(req, res); if (!t) return;++ const { kind, value, id } = req.body || {};++ if (!['screen', 'input', 'browser'].includes(kind) || !value || typeof value !== 'object' || Array.isArray(value)) return res.status(400).json({ code: 'computer.action.invalid', detail: 'Send a Run screen, input or browser command.' });++ res.json(await run.command(t, req.params.id, { kind, value, ...(id ? { id } : {}) }));++ }));++ r.post('/computers/:id/handoff', wrap(async (req, res) => {++ const t = token(req, res); if (!t) return;++ await run.get(t, req.params.id);++ res.status(501).type('application/problem+json').json({ type: 'about:blank', title: 'Run handoff unavailable', status: 501, code: 'computer.handoff.pending', detail: 'Run does not expose B3 control modes or a handoff route yet.' });++ }));++ return r;++}++module.exports = { createApi };nnew file mode 100644@@@ -0,0 +1,37 @@++'use strict';++const path = require('path');++const express = require('express');++const helmet = require('helmet');++const { createSsoClient } = require('openvibe-sdk/sso');++const { createRunClient } = require('./run-client');++const { createApi } = require('./api');++const { createPages } = require('./pages');++const configLib = require('./config');++++function createApp(opts = {}) {++ const config = opts.config || configLib.load();++ const sso = opts.sso || createSsoClient({ site: 'computer', baseUrl: config.baseUrl, clientId: config.clientId, clientSecret: config.clientSecret, networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl, secureCookies: config.secureCookies });++ const run = opts.run || createRunClient({ baseUrl: config.runUrl, fetchImpl: opts.fetchImpl });++ const app = express();++ app.disable('x-powered-by'); app.set('trust proxy', config.trustProxy);++ app.use(helmet({ contentSecurityPolicy: { directives: {++ defaultSrc: ["'self'"], scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network'], styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com'], fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'], imgSrc: ["'self'", 'data:', 'https://openvibe.network'], connectSrc: ["'self'", 'https://openvibe.network', 'https://openvibe.chat', 'https://billing.openvibe.network', 'https://openvibe.events'], frameSrc: ["'self'", 'https://openvibe.network'], frameAncestors: ["'none'"], objectSrc: ["'none'"], baseUri: ["'self'"], formAction: ["'self'", 'https://openvibe.network'],++ } }, crossOriginEmbedderPolicy: false, referrerPolicy: { policy: 'strict-origin-when-cross-origin' } }));++ app.use(express.urlencoded({ extended: false }));++ app.use('/auth', sso.router(express));++ app.use(sso.optionalAuth());++ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-computer', version: require('../package.json').version }));++ app.get('/robots.txt', (_req, res) => res.type('text/plain').send('User-agent: *\nAllow: /\nDisallow: /api/\nSitemap: https://openvibe.computer/sitemap.xml\n'));++ app.get('/sitemap.xml', (_req, res) => res.type('application/xml').send('<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://openvibe.computer/</loc></url><url><loc>https://openvibe.computer/templates</loc></url><url><loc>https://openvibe.computer/pricing</loc></url><url><loc>https://openvibe.computer/docs</loc></url><url><loc>https://openvibe.computer/updates</loc></url></urlset>'));++ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'computer', service: 'computer', host: 'openvibe.computer', name: 'OpenVibe.Computer', profile: 'hosting' })); }++ app.get('/llms.txt', (_req, res) => res.type('text/plain').send('# OpenVibe.Computer\n\nA computer for you, your AI, or both. Run owns sessions, placement and budgets.\n\n- [Templates](/templates)\n- [Pricing](/pricing)\n- [API docs](/docs)\n- [OpenAPI](/openapi.json)\n- [Agent descriptor](/.well-known/agent.json)\n'));++ app.get('/openapi.json', (_req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'openapi.json')));++ app.get('/.well-known/agent.json', (_req, res) => res.sendFile(path.join(__dirname, '..', 'public', '.well-known', 'agent.json')));++ app.use('/api/v1', createApi({ run, sso }));++ app.use('/shared', require('openvibe-shared/serve').handler());++ app.use(express.static(path.join(__dirname, '..', 'public'), { index: false, maxAge: '1h' }));++ app.use(createPages({ config, run, sso }));++ app.use((err, _req, res, _next) => { if (res.headersSent) return; res.status(err.status || 500).type('text/plain').send('Computer could not complete that request.'); });++ return { app, config, run, sso };++}++module.exports = { createApp };nnew file mode 100644@@@ -0,0 +1,20 @@++'use strict';++require('dotenv').config();++const trim = (s) => String(s || '').replace(/\/+$/, '');++const numberOrNull = (v) => v === '' || v == null || !Number.isFinite(Number(v)) || Number(v) < 0 ? null : Number(v);++function load(env = process.env) {++ const port = Number.parseInt(env.PORT, 10) || 5135;++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');++ return {++ port, host: env.HOST || '127.0.0.1', baseUrl: trim(env.BASE_URL || `http://localhost:${port}`),++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,++ networkUrl, networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),++ runUrl: trim(env.OV_RUN_URL || 'https://openvibe.run'),++ clientId: env.OV_OAUTH_CLIENT_ID || 'computer', clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',++ browserFreeMinutes: numberOrNull(env.COMPUTER_BROWSER_FREE_MINUTES),++ desktopFreeMinutes: numberOrNull(env.COMPUTER_DESKTOP_FREE_MINUTES),++ pricePerMinute: numberOrNull(env.COMPUTER_PRICE_PER_MINUTE),++ secureCookies: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : env.NODE_ENV === 'production',++ };++}++module.exports = { load };nnew file mode 100644@@@ -0,0 +1,3 @@++'use strict';++const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);++module.exports = { esc };nnew file mode 100644@@@ -0,0 +1,4 @@++'use strict';++const { createApp } = require('./app');++const { app, config } = createApp();++app.listen(config.port, config.host, () => console.log(`[Computer] listening on ${config.host}:${config.port}`));nnew file mode 100644@@@ -0,0 +1,17 @@++'use strict';++const shell = require('openvibe-shared/shell');++const serve = require('openvibe-shared/serve');++const { esc } = require('./html');++const NAV = [++ { label: 'Home', href: '/' }, { label: 'Templates', href: '/templates' }, { label: 'Pricing', href: '/pricing' },++ { label: 'Docs', href: '/docs' }, { label: 'Updates', href: '/updates' },++];++function page({ title, body, viewer, config, path = '/' }) {++ const nav = { service: 'computer', apiBase: config.networkUrl, links: NAV.map((x) => ({ ...x, active: path === x.href })), history: { type: 'page', title }, silentLogin: `${config.baseUrl}/auth/login?silent=1&next={url}`, sessionUrl: '/auth/me', loginUrl: '/auth/login?next={path}', logoutUrl: '/auth/logout?next={path}' };++ const footer = { service: 'computer', variant: 'full', brandName: 'OpenVibe.Computer' };++ return shell.page({ name: 'OpenVibe.Computer', lang: 'en', title: `${title} · OpenVibe.Computer`, titleSuffix: '', siteName: 'OpenVibe.Computer', description: 'A computer for you, your AI, or both.', canonical: `${config.baseUrl}${path}`, robots: 'index, follow', home: '/', navLinks: NAV, navbar: nav, footer,++ head: `<link rel="stylesheet" href="${serve.url('kit.css')}"><link rel="stylesheet" href="/css/computer.css"><meta name="referrer" content="strict-origin-when-cross-origin">`,++ body: `<div id="navbar-mount"></div><main id="main" class="computer-page">${body}</main>` });++}++function send(res, opts, status = 200) { res.set('Cache-Control', opts.viewer?.kind === 'user' ? 'private, no-store' : 'public, max-age=60'); res.set('Vary', 'Cookie'); res.status(status).type('html').send(page(opts)); }++module.exports = { page, send, NAV };nnew file mode 100644@@@ -0,0 +1,54 @@++'use strict';++const express = require('express');++const crypto = require('crypto');++const { esc } = require('./html');++const { send } = require('./layout');++const { priceText, freeText, kinds, templates, priceLine } = require('./product');++++function createPages({ config, run, sso }) {++ const r = express.Router();++ const viewerOf = (req) => req.user || req.viewer || { kind: 'anonymous' };++ const tokenOf = (req) => sso.extractToken(req);++ const card = (k) => `<article class="kind-card kind-card--ready"><span class="eyebrow">${esc(k.badge)}</span><h3>${esc(k.name)}</h3><p>${esc(k.for)}</p><p class="runs">Runs on ${esc(k.runs)}</p><form method="post" action="/start"><input type="hidden" name="kind" value="${esc(k.id)}"><button class="button-primary" type="submit">Start a ${esc(k.name.toLowerCase())}</button></form></article>`;++ const soon = (k) => `<li class="kind-soon"><b>${esc(k.name)}</b><span>${esc(k.for)}</span><small>${esc(k.runs.replace(/\.$/, ''))}</small></li>`;++ r.get('/', async (req, res) => {++ const viewer = viewerOf(req); let sessions = [];++ if (viewer.kind === 'user') { try { sessions = (await run.list(tokenOf(req), '?state=all&limit=8')).sessions || []; } catch { /* show dashboard state without hiding the service page */ } }++ const computerRows = sessions.map((s) => `<li><a href="/computers/${encodeURIComponent(s.id)}"><strong>${esc(s.kind === 'browser' ? 'Browser computer' : s.kind)}</strong></a><span class="status">${esc(s.state)}</span><span>${esc(s.id)}</span><a href="/computers/${encodeURIComponent(s.id)}">Open</a>${s.state !== 'ended' ? `<form method="post" action="/computers/${encodeURIComponent(s.id)}/stop"><button class="button-quiet">Stop</button></form>` : ''}</li>`).join('');++ const body = `<section class="hero"><div><span class="eyebrow">YOUR COMPUTER, WHEREVER YOU ARE</span><h1>A computer for you, your AI, or both.</h1><p>Start a browser desk on OpenVibe Run. Choose where it runs, keep control close, and bring your own Link machine for free use.</p><a class="button-primary" href="#start">Start a computer</a> <a class="button-secondary" href="/templates">Explore templates</a></div></section>++ <section id="start"><div class="section-heading"><div><span class="eyebrow">PICK A COMPUTER</span><h2>Start a computer</h2></div><p>Each session runs where its kind is supported. Run chooses placement and enforces budgets.</p></div><p class="price-line">${esc(priceLine(config))} <a href="/pricing">Pricing</a></p><div class="kind-grid">${kinds(config).filter((k) => k.available).map(card).join('')}</div>++ <h3 class="soon-h">Coming as Run adds them <span class="muted">${kinds(config).filter((k) => !k.available).length}</span></h3><ul class="kind-soon-grid">${kinds(config).filter((k) => !k.available).map(soon).join('')}</ul></section>++ ${viewer.kind === 'user' ? `<section class="your-computers"><div class="section-heading"><div><span class="eyebrow">YOUR WORKSPACE</span><h2>Your computers</h2></div><a href="/api/v1/computers">Open API</a></div>${computerRows ? `<ul class="computer-list">${computerRows}</ul>` : '<p>No running or recent computers yet. Start a browser computer to see it here.</p>'}</section>` : `<section class="sign-in-callout"><h2>Keep your computers close</h2><p>Sign in to open, stop and manage your sessions.</p><a class="button-secondary" href="/auth/login?next=%2F">Sign in with OpenVibe</a></section>`}`;++ send(res, { title: 'A computer for you, your AI, or both', body, viewer, config, path: '/' });++ });++ r.post('/start', express.urlencoded({ extended: false }), async (req, res) => {++ const kind = String(req.body.kind || 'browser');++ if (kind !== 'browser') return res.redirect(303, '/#start');++ if (viewerOf(req).kind !== 'user') return res.redirect(303, `/auth/login?next=${encodeURIComponent('/')}`);++ const viewer = viewerOf(req);++ try { const session = await run.create(tokenOf(req), { kind: 'browser', idempotency_key: `computer:${crypto.randomUUID()}` }); return res.redirect(303, `/computers/${encodeURIComponent(session.id)}`); }++ catch (e) { send(res, { title: 'Could not start computer', body: `<section class="page-intro"><h1>Could not start this computer</h1><p>${esc(e.message)}</p><a href="/#start">Try again</a></section>`, viewer, config, path: '/' }, e.status || 503); }++ });++ r.get('/computers/:id', async (req, res) => {++ const viewer = viewerOf(req); if (viewer.kind !== 'user') return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);++ try {++ const session = await run.get(tokenOf(req), req.params.id);++ const modes = ['You drive, your AI watches', 'Your AI drives, you watch', 'Your AI drives and asks first', 'Together', 'Guests watch', 'Paused'];++ const body = `<section class="page-intro"><a href="/">← Your computers</a><div class="title-row"><div><span class="eyebrow">${esc(session.state)} · ${esc(session.id)}</span><h1>Browser computer</h1></div><form method="post" action="/computers/${encodeURIComponent(session.id)}/stop"><button class="button-danger" ${session.state === 'ended' ? 'disabled' : ''}>Stop computer</button></form></div><p>Placed by OpenVibe Run. ${esc(session.placement?.machine || 'Run is choosing a machine')} · ${esc(session.usage?.desk_seconds || 0)} seconds used.</p></section>++ <section class="screen-panel"><div class="screen-placeholder"><span class="screen-led"></span><h2>Live screen connects through Run</h2><p>Run currently supports one off screen commands. Its live desk stream is not exposed yet.</p></div><div class="mode-panel"><span class="eyebrow">WHO HOLDS THE CONTROLS</span><h2>Control mode</h2><ul class="mode-list">${modes.map((m) => `<li>${esc(m)} <span>Planned</span></li>`).join('')}</ul><form method="post" action="/computers/${encodeURIComponent(session.id)}/handoff"><button class="button-primary" disabled title="Arrives with Run's control lease">Take over</button></form><p class="fine-print">Run does not expose a control handoff or B3 mode yet. This request checks the session with Run and makes no change until Run adds that support.</p></div></section><section class="timeline"><span class="eyebrow">ACTIVITY</span><h2>Action timeline</h2><p>Run does not expose a session action timeline yet.</p><button class="button-secondary" disabled>Snapshot · coming when Run supports it</button></section>`;++ send(res, { title: 'Your browser computer', body, viewer, config, path: req.path });++ } catch (e) { send(res, { title: 'Computer unavailable', body: `<section class="page-intro"><h1>Computer unavailable</h1><p>${esc(e.message)}</p><a href="/">Back to your computers</a></section>`, viewer, config, path: req.path }, e.status || 503); }++ });++ r.post('/computers/:id/stop', async (req, res) => { try { await run.stop(tokenOf(req), req.params.id); res.redirect(303, '/'); } catch (e) { send(res, { title: 'Could not stop computer', body: `<h1>Could not stop computer</h1><p>${esc(e.message)}</p><a href="/">Back</a>`, viewer: viewerOf(req), config }, e.status || 503); } });++ r.post('/computers/:id/handoff', async (req, res) => {++ const viewer = viewerOf(req); if (viewer.kind !== 'user') return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);++ try { await run.get(tokenOf(req), req.params.id); send(res, { title: 'Take over', body: '<section class="page-intro"><h1>Take over is waiting on Run</h1><p>Run does not expose the B3 control lease yet, so this session was not changed.</p><a href="/computers">Back to computers</a></section>', viewer, config }); }++ catch (e) { send(res, { title: 'Could not take over', body: `<h1>Could not take over</h1><p>${esc(e.message)}</p>`, viewer, config }, e.status || 503); }++ });++ r.get('/templates', (_req, res) => send(res, { title: 'Computer templates', body: `<section class="page-intro"><span class="eyebrow">ONE CLICK SETUPS</span><h1>Templates</h1><p>Choose a starting point for the computer you need. Run's current session API starts browser desks; templates wait on Run and the broker.</p></section><div class="template-grid">${templates.map((t) => `<article class="template-card"><span class="template-mark">${esc(t.mark)}</span><h2>${esc(t.name)}</h2><p>${esc(t.text)}</p><span class="availability">Coming as Run adds template support</span></article>`).join('')}</div>`, viewer: viewerOf(_req), config, path: '/templates' }));++ r.get('/pricing', (_req, res) => send(res, { title: 'Pricing', body: `<section class="page-intro"><span class="eyebrow">CLEAR BEFORE YOU START</span><h1>Pricing</h1><p>Run enforces the session budget and placement. Prices become final when configured by the owner.</p></section><div class="price-grid"><article><h2>Your Link machines</h2><p>Free, with no time limit.</p></article><article><h2>Browser allowance</h2><p>${freeText(config.browserFreeMinutes, '20 hours per month (proposed)')}</p></article><article><h2>Desktop allowance</h2><p>${freeText(config.desktopFreeMinutes, '1 hour per month (proposed)')}</p></article><article><h2>After the allowance</h2><p>${priceText(config.pricePerMinute)}</p></article></div><p class="fine-print">The free allowances and per-minute price come from service configuration. Values marked proposed are not yet owner-set.</p>`, viewer: viewerOf(_req), config, path: '/pricing' }));++ r.get('/docs', (_req, res) => send(res, { title: 'Computer API docs', body: `<section class="page-intro"><span class="eyebrow">BUILD ON COMPUTER</span><h1>API documentation</h1><p>Computer forwards each signed-in person's Network token to Run. Run owns budgets, placement and session state.</p><p><a href="/openapi.json">OpenAPI 3.1</a> · <a href="/.well-known/agent.json">Agent descriptor</a></p></section><pre>GET /api/v1/computers\nPOST /api/v1/computers\nGET /api/v1/computers/{id}\nPOST /api/v1/computers/{id}/stop\nPOST /api/v1/computers/{id}/screenshot\nPOST /api/v1/computers/{id}/act\nPOST /api/v1/computers/{id}/handoff</pre><p>Use a user Network token with the matching Run session capability. Computer does not use a service token on the person's behalf.</p>`, viewer: viewerOf(_req), config, path: '/docs' }));++ r.get('/updates', (_req, res) => send(res, { title: 'Updates', body: '<section class="page-intro"><span class="eyebrow">UPDATES</span><h1>Computer is taking shape</h1><p>The first front door uses Run browser sessions. Desktop, GPU and always-on sessions follow as placement and templates arrive in Run.</p></section>', viewer: viewerOf(_req), config, path: '/updates' }));++ return r;++}++module.exports = { createPages };nnew file mode 100644@@@ -0,0 +1,32 @@++'use strict';++const minutes = (n) => `${Math.floor(n / 60)} ${Math.floor(n / 60) === 1 ? 'hour' : 'hours'}${n % 60 ? ` ${n % 60} minutes` : ''} per month`;++const freeText = (value, proposed) => value == null ? `Not configured · ${proposed}` : `${minutes(value)} included`;++const priceText = (value) => value == null ? 'Not configured yet · proposed per-minute billing will be shown before a paid session starts.' : `$${value.toFixed(4)} per minute`;++function kinds(config) {++ const configuredBrowser = freeText(config.browserFreeMinutes, '20 hours monthly allowance proposed');++ const price = priceText(config.pricePerMinute);++ return [++ { id: 'browser', name: 'Browser', badge: 'QUICK TASKS', for: 'A private browser for a task, an AI errand, or a shared screen.', runs: 'OpenVibe workers or your Link machines.', price: `Your Link machine: free · Cloud: ${configuredBrowser}; then ${price}`, available: true },++ { id: 'kept-browser', name: 'Kept browser', badge: 'YOUR SITES', for: 'A browser that stays signed in with an encrypted profile.', runs: 'OpenVibe workers or your Link machines.', price: `Your Link machine: free · Cloud: ${configuredBrowser}; then ${price}`, available: false },++ { id: 'desktop', name: 'Desktop', badge: 'FULL APPS', for: 'A full Linux desktop for apps and longer work.', runs: 'OpenVibe workers or Link.', price: `Your Link machine: free · ${freeText(config.desktopFreeMinutes, '1 free hour proposed')}; then ${price}`, available: false },++ { id: 'gpu-desktop', name: 'GPU desktop', badge: 'RENDER & PLAY', for: 'A graphics desktop for Blender, video, local models and games.', runs: 'Home GPU, rented GPU or a Link GPU.', price: 'GPU cost and allowance not configured · proposed', available: false },++ { id: 'always-on', name: 'Always-on', badge: 'FOR YOUR AGENT', for: 'A computer that stays ready between agent tasks.', runs: 'OpenVibe workers.', price: `Cloud: ${price}`, available: false },++ { id: 'your-computer', name: 'Your computer', badge: 'FROM ANYWHERE', for: 'Reach your own computer from anywhere.', runs: 'Link with Desktop capability.', price: 'Free on your own Link machine.', available: false },++ { id: 'throwaway', name: 'Throwaway', badge: 'NOTHING KEPT', for: 'A temporary computer that is wiped when it ends.', runs: 'OpenVibe workers.', price: `Cloud: ${configuredBrowser}; then ${price}`, available: false },++ ];++}++const templates = [++ { mark: '01', name: 'Streaming box', text: 'OBS ready for a stream to OpenVibe Live.' },++ { mark: '02', name: 'Studio box', text: 'A home for the OpenVibe creative apps.' },++ { mark: '03', name: 'Code box', text: 'An IDE alongside your OpenVibe Codes work.' },++ { mark: '04', name: 'Blender box', text: 'A rendering setup with the right GPU.' },++ { mark: '05', name: 'Games box', text: 'A computer set up for games and a shared screen.' },++ { mark: '06', name: 'Research box', text: 'A browser with your Vibe in the Dock.' },++];++/** The whole price in one sentence (it was repeated, half-configured, on every card). */++function priceLine(config) {++ const browser = config.browserFreeMinutes == null ? '20 hours of cloud browser a month (proposed)' : `${minutes(config.browserFreeMinutes)} of cloud browser`;++ const after = config.pricePerMinute == null ? 'then per minute at a price shown before any paid session starts' : `then $${config.pricePerMinute.toFixed(4)} a minute`;++ return `Free and unlimited on your own Link machines. In the cloud: ${browser}, ${after}.`;++}++module.exports = { kinds, templates, freeText, priceText, priceLine };nnew file mode 100644@@@ -0,0 +1,24 @@++'use strict';++class RunError extends Error {++ constructor(status, body) { super(body.detail || body.title || `Run returned ${status}`); this.status = status; this.code = body.code || 'run.upstream_error'; this.body = body; }++}++++function createRunClient({ baseUrl, fetchImpl = globalThis.fetch }) {++ async function request(token, path, { method = 'GET', body } = {}) {++ const res = await fetchImpl(`${baseUrl}/api/v1${path}`, {++ method, headers: { authorization: `Bearer ${token}`, accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}) },++ ...(body ? { body: JSON.stringify(body) } : {}),++ });++ const data = await res.json().catch(() => ({}));++ if (!res.ok) throw new RunError(res.status, data);++ return data;++ }++ return {++ list: (token, query = '') => request(token, `/sessions${query}`),++ get: (token, id) => request(token, `/sessions/${encodeURIComponent(id)}`),++ create: (token, body) => request(token, '/sessions', { method: 'POST', body }),++ stop: (token, id) => request(token, `/sessions/${encodeURIComponent(id)}/end`, { method: 'POST', body: {} }),++ command: (token, id, body) => request(token, `/sessions/${encodeURIComponent(id)}/commands`, { method: 'POST', body }),++ };++}++module.exports = { createRunClient, RunError };nnew file mode 100644@@@ -0,0 +1,72 @@++'use strict';++const test = require('node:test');++const assert = require('node:assert/strict');++const http = require('node:http');++const express = require('express');++const { createApp } = require('../server/app');++const { createRunClient } = require('../server/run-client');++++const config = { port: 0, host: '127.0.0.1', baseUrl: 'http://computer.test', networkUrl: 'https://openvibe.network', runUrl: 'https://openvibe.run', browserFreeMinutes: null, desktopFreeMinutes: null, pricePerMinute: null };++function fakeSso() {++ return {++ router: () => express.Router(),++ optionalAuth: () => (req, _res, next) => { if ((req.headers.cookie || '').includes('ov_token=person-a')) req.user = { kind: 'user', sub: 'usr_a', displayName: 'Person A' }; next(); },++ extractToken: (req) => { const auth = req.headers.authorization || ''; if (auth.startsWith('Bearer ')) return auth.slice(7); const m = (req.headers.cookie || '').match(/(?:^|;\s*)ov_token=([^;]+)/); return m && m[1] || null; },++ };++}++async function serve(opts) {++ const { app } = createApp({ config, sso: fakeSso(), ...opts });++ const server = http.createServer(app); await new Promise((r) => server.listen(0, '127.0.0.1', r));++ return { base: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) };++}++++test('home renders signed out and signed in; sign-in and computer controls are visible', async (t) => {++ const calls = [];++ const run = { list: async (token) => { calls.push(['list', token]); return { sessions: [{ id: 'ses_a', kind: 'browser', state: 'running', usage: { desk_seconds: 12 } }] }; }, get: async (token, id) => { calls.push(['get', token, id]); return { id, kind: 'browser', state: 'running', usage: { desk_seconds: 12 } }; }, stop: async () => ({}) };++ const s = await serve({ run }); t.after(s.close);++ const guest = await fetch(s.base); assert.equal(guest.status, 200); assert.match(await guest.text(), /Sign in with OpenVibe/);++ const home = await fetch(s.base, { headers: { cookie: 'ov_token=person-a' } }); const html = await home.text();++ assert.match(html, /Your computers/); assert.match(html, /Browser/); assert.match(html, /20 hours of cloud browser a month \(proposed\)/); assert.match(html, /Coming as Run adds them/); assert.doesNotMatch(html, /hero-art|account-line/);++ const detail = await fetch(`${s.base}/computers/ses_a`, { headers: { cookie: 'ov_token=person-a' } }); const page = await detail.text();++ for (const mode of ['You drive, your AI watches', 'Your AI drives, you watch', 'Your AI drives and asks first', 'Together', 'Guests watch', 'Paused']) assert.match(page, new RegExp(mode));++ assert.match(page, /Take over/); assert.equal(calls[0][1], 'person-a'); assert.deepEqual(calls[1], ['get', 'person-a', 'ses_a']);++ for (const path of ['/templates', '/pricing', '/docs', '/updates']) { const r = await fetch(`${s.base}${path}`); assert.equal(r.status, 200, `${path} renders signed out`); }++ for (const path of ['/templates', '/pricing', '/docs', '/updates']) { const r = await fetch(`${s.base}${path}`, { headers: { cookie: 'ov_token=person-a' } }); assert.equal(r.status, 200, `${path} renders signed in`); }++ assert.equal((await fetch(`${s.base}/openapi.json`)).status, 200); assert.equal((await fetch(`${s.base}/.well-known/agent.json`)).status, 200); assert.equal((await fetch(`${s.base}/llms.txt`)).status, 200);++});++++test('facade forwards only the presented person token to Run', async (t) => {++ const received = [];++ const run = { list: async (token) => { received.push(token); return { sessions: [] }; }, create: async (token, body) => { received.push(token); return { id: 'ses_new', ...body }; } };++ const s = await serve({ run }); t.after(s.close);++ assert.equal((await fetch(`${s.base}/api/v1/computers`)).status, 401);++ const list = await fetch(`${s.base}/api/v1/computers`, { headers: { authorization: 'Bearer user-token-A' } }); assert.equal(list.status, 200);++ const create = await fetch(`${s.base}/api/v1/computers`, { method: 'POST', headers: { authorization: 'Bearer user-token-B', 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'browser' }) }); assert.equal(create.status, 201);++ assert.deepEqual(received, ['user-token-A', 'user-token-B']);++});++++test('starting from the home page is a signed-in POST to Run', async (t) => {++ const calls = []; const run = { create: async (token, body) => { calls.push([token, body]); return { id: 'ses_started' }; } };++ const s = await serve({ run }); t.after(s.close);++ const guest = await fetch(`${s.base}/start`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'kind=browser', redirect: 'manual' });++ assert.equal(guest.status, 303); assert.match(guest.headers.get('location'), /auth\/login/); assert.equal(calls.length, 0);++ const signed = await fetch(`${s.base}/start`, { method: 'POST', headers: { cookie: 'ov_token=person-a', 'content-type': 'application/x-www-form-urlencoded' }, body: 'kind=browser', redirect: 'manual' });++ assert.equal(signed.status, 303); assert.equal(signed.headers.get('location'), '/computers/ses_started');++ assert.equal(calls[0][0], 'person-a'); assert.equal(calls[0][1].kind, 'browser'); assert.match(calls[0][1].idempotency_key, /^computer:/);++});++++test('screen, act and stop map to Run; handoff reports unsupported Run capability', async (t) => {++ const calls = []; const run = { command: async (token, id, body) => { calls.push([token, id, body]); return { result: { outcome: 'ack' } }; }, stop: async (token, id) => { calls.push([token, id, 'stop']); return { state: 'ended' }; }, get: async (token, id) => { calls.push([token, id, 'get']); return { id, state: 'running' }; } };++ const s = await serve({ run }); t.after(s.close); const headers = { authorization: 'Bearer user-token-A', 'content-type': 'application/json' };++ assert.equal((await fetch(`${s.base}/api/v1/computers/ses_a/screenshot`, { method: 'POST', headers, body: '{}' })).status, 200);++ assert.equal((await fetch(`${s.base}/api/v1/computers/ses_a/act`, { method: 'POST', headers, body: JSON.stringify({ kind: 'browser', value: { action: 'read' } }) })).status, 200);++ assert.equal((await fetch(`${s.base}/api/v1/computers/ses_a/stop`, { method: 'POST', headers, body: '{}' })).status, 200);++ const handoff = await fetch(`${s.base}/api/v1/computers/ses_a/handoff`, { method: 'POST', headers, body: '{}' }); assert.equal(handoff.status, 501);++ assert.equal(calls[0][2].kind, 'screen'); assert.equal(calls[1][2].kind, 'browser'); assert.deepEqual(calls[2], ['user-token-A', 'ses_a', 'stop']); assert.deepEqual(calls[3], ['user-token-A', 'ses_a', 'get']);++});++++test('Run client sends the original bearer token on each upstream request', async () => {++ const seen = []; const client = createRunClient({ baseUrl: 'https://run.example', fetchImpl: async (url, options) => { seen.push([url, options.headers.authorization, options.method]); return { ok: true, json: async () => ({ sessions: [] }) }; } });++ await client.list('one-token'); await client.get('another-token', 'ses_abc');++ assert.deepEqual(seen, [['https://run.example/api/v1/sessions', 'Bearer one-token', 'GET'], ['https://run.example/api/v1/sessions/ses_abc', 'Bearer another-token', 'GET']]);++});
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+PORT=5135
+BASE_URL=http://localhost:5135
+OV_NETWORK_URL=https://openvibe.network
+OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
+OV_OAUTH_CLIENT_ID=computer
+OV_OAUTH_CLIENT_SECRET=
+OV_RUN_URL=https://openvibe.run
+# Empty means the owner has not set the allowance or price yet.
+COMPUTER_BROWSER_FREE_MINUTES=
+COMPUTER_DESKTOP_FREE_MINUTES=
+COMPUTER_PRICE_PER_MINUTE=
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+permissions:
+ contents: read
+jobs:
+ test:
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22.22.1
+ cache: npm
+ - run: npm ci --no-audit --no-fund
+ - name: syntax
+ run: find server test -name '*.js' -print0 | xargs -0 -n1 node --check
+ - run: npm test
+ security:
+ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+node_modules/
+.env
+.env.*
+!.env.example
+*.log
+data/
+dist/
+
@@ -1,2 +1,23 @@
@@ -1,2 +1,23 @@
# OpenVibe.Computer
# OpenVibe.Computer
-The front door and API facade for OpenVibe computer sessions
+
+The front door and API facade for OpenVibe computers. Run owns sessions, placement and budgets; this service renders the product and forwards a person's Network token to Run unchanged.
+
+## Run locally
+
+Node 22 or newer is required.
+
+```sh
+npm ci
+cp .env.example .env
+npm run dev
+```
+
+The site listens on `127.0.0.1:5135`. Configure `OV_OAUTH_CLIENT_SECRET` for Network sign-in and `OV_RUN_URL` for Run. Allowance and per-minute settings are intentionally unset until the owner chooses them.
+
+## Run support today
+
+The current Run session API supports browser session create/list/read/end and one-off screen, input and browser commands. Placement and budget enforcement remain in Run. Run does not yet expose live session streams, B3 control modes, control handoff, action timelines, snapshots, non-browser computer kinds or templates. The UI marks those capabilities as pending.
+
+## Launch notes
+
+See [deploy/LAUNCH.md](deploy/LAUNCH.md) for the DNS, Cloudflare, OAuth and vhost sequence. The host-level parked-redirect swap is intentionally a post-review action.
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+# Launch sequence for openvibe.computer
+
+This PR prepares the service and host configuration. Opus performs the host-level swap after review; do not remove the parked redirect before the Computer vhost and service are ready.
+
+1. **DNS and Cloudflare:** make sure the `openvibe.computer` zone is active at Cloudflare. Add apex and `www` A/AAAA records to the OpenVibe host, proxied through Cloudflare like the other room domains. Keep the DNS-only ACME validation route reachable during certificate issuance. Confirm `openvibe.computer` and `www.openvibe.computer` resolve to the intended host.
+2. **TLS:** issue `/etc/letsencrypt/live/openvibe.computer/{fullchain.pem,privkey.pem}` for apex and `www` before enabling the TLS server block.
+3. **Network OAuth:** deploy the Network PR with the Computer cutover. It seeds confidential OAuth client `computer`, redirect URI `https://openvibe.computer/auth/callback`, and lists Computer in the Network Frame. The same host change installs Computer's vhost before removing the old redirect.
+4. **Service:** after the OpenVibe.Host inventory PR is available, run `sudo ovhost service add computer`. It adds the service entry, clones the repo, creates `/etc/openvibe/computer.env` with mode 0600 and prints the remaining setup steps without printing secrets. Use Network's service-principal setup to create or rotate client `computer`; set `OV_RUN_URL=https://openvibe.run` and `OV_OAUTH_CLIENT_ID=computer`, then run `sudo ovhost validate computer` and `sudo ovhost deploy computer --restart`. Check `http://127.0.0.1:5135/api/health`.
+5. **Vhost and parked redirect:** after review and successful readiness checks, Opus removes the `openvibe.computer` redirect blocks from Network's `parked-domains.conf`, installs `deploy/nginx/openvibe.computer.conf` with `sudo ovhost nginx render computer --install`, runs `nginx -t`, and reloads nginx as one host-level swap. The Host inventory keeps `repoVhost` review-only so a normal Computer deploy does not install the vhost early.
+6. **Verify:** check `/`, `/api/health`, and `/auth/login` through HTTPS. The vhost leaves `/api/` public to the service; user endpoints require a Network token and Run authorizes the forwarded token.
+
+No host change is part of this PR.
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+## openvibe.computer — reference vhost for ov-vhost-install.
+## Computer answers pages and /api/ on 127.0.0.1:5135. The facade forwards the caller's token to Run server-side.
+limit_req_zone $binary_remote_addr zone=computer_auth:10m rate=10r/m;
+limit_req_zone $binary_remote_addr zone=computer_api:10m rate=5r/s;
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name openvibe.computer www.openvibe.computer;
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://openvibe.computer$request_uri; }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name www.openvibe.computer;
+ ssl_certificate /etc/letsencrypt/live/openvibe.computer/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.computer/privkey.pem;
+ return 301 https://openvibe.computer$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name openvibe.computer;
+ ssl_certificate /etc/letsencrypt/live/openvibe.computer/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.computer/privkey.pem;
+ access_log /var/log/nginx/openvibe.computer.access.log;
+ error_log /var/log/nginx/openvibe.computer.error.log;
+ client_max_body_size 128k;
+ add_header X-Content-Type-Options nosniff always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+ gzip on;
+ gzip_proxied any;
+ gzip_vary on;
+ gzip_comp_level 5;
+ gzip_min_length 1024;
+ gzip_types text/css application/javascript application/json application/problem+json text/plain;
+ 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 X-Forwarded-Proto $scheme;
+ location = /auth/me { limit_req zone=computer_api burst=30 nodelay; limit_req_status 429; proxy_pass http://127.0.0.1:5135; }
+ location /auth/ { limit_req zone=computer_auth burst=10 nodelay; limit_req_status 429; proxy_pass http://127.0.0.1:5135; }
+ location /api/ { limit_req zone=computer_api burst=20 nodelay; limit_req_status 429; proxy_pass http://127.0.0.1:5135; }
+ location ~* ^/(metrics|internal)(/|$) { return 404; }
+ location ~ /\.(?!well-known) { deny all; }
+ location / { proxy_pass http://127.0.0.1:5135; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+[Unit]
+Description=OpenVibe.Computer — the front door to your computers.
+After=network.target
+Wants=openvibe-network.service
+
+[Service]
+Type=simple
+User=ubuntu
+Group=ubuntu
+WorkingDirectory=/opt/openvibe.computer
+EnvironmentFile=/etc/openvibe/computer.env
+Environment=NODE_ENV=production
+Environment=PORT=5135
+Environment=HOST=127.0.0.1
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/bin/env node /opt/openvibe.computer/server/index.js
+Restart=always
+RestartSec=5
+KillSignal=SIGTERM
+TimeoutStopSec=15
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+
+[Install]
+WantedBy=multi-user.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,1416 @@
@@ -0,0 +1,1416 @@
+{
+ "name": "openvibe-computer",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-computer",
+ "version": "0.1.0",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "cookie-parser": "^1.4.7",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "helmet": "^8.1.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.346.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.1",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "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/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/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/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": "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-parser": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "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/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "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/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-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.9",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
+ "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "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/helmet": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
+ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/EvanHahn"
+ }
+ },
+ "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/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/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-contracts": {
+ "version": "0.346.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.346.0",
+ "integrity": "sha512-ncmROx6i7VQa6l1FitWZhPArFEYskrIbUWTdMHKty7c5m6OIYkjh8GRm9HxzfwH9vD89isTzQiX16yfU5NYHhw==",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1"
+ },
+ "bin": {
+ "openvibe-contracts-check": "bin/check-service.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/openvibe-sdk": {
+ "version": "0.97.1",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.1",
+ "integrity": "sha512-zCRmu7NoOi7kI0v04N8uypCgsuEcf9kCzkw88r0+dj9PG3AA/z+Pp4BZaX6Z1JUE3sdrtc17D5LcD05IbWsw8w==",
+ "license": "MIT",
+ "bin": {
+ "openvibe-download": "bin/openvibe-download.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0"
+ },
+ "peerDependenciesMeta": {
+ "@electric-sql/pglite": {
+ "optional": true
+ },
+ "iovalkey": {
+ "optional": true
+ },
+ "pg": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/openvibe-shared": {
+ "version": "4.135.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0",
+ "integrity": "sha512-+v59lQjCjkb8poKNyKOKfTr8SoFMyr/4dKMoIpPgxqvn8MDFYpylvI80U3AvSxeJTqZpthhrBBdkgVlok/M3dg==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0",
+ "linkedom": "^0.18.13"
+ },
+ "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-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/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/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/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/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"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "name": "openvibe-computer",
+ "version": "0.1.0",
+ "private": true,
+ "description": "The front door and API facade for OpenVibe computer sessions",
+ "main": "server/index.js",
+ "scripts": { "start": "node server/index.js", "dev": "NODE_ENV=development node server/index.js", "test": "node --test test/*.test.js" },
+ "engines": { "node": ">=22.0.0" },
+ "dependencies": {
+ "cookie-parser": "^1.4.7",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "helmet": "^8.1.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.346.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.97.1",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0"
+ },
+ "license": "AGPL-3.0-only"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+{
+ "name": "OpenVibe.Computer",
+ "description": "Start and manage your OpenVibe computer sessions. Run owns placement, budgets and session state.",
+ "url": "https://openvibe.computer",
+ "version": "1.0.0",
+ "documentation": "https://openvibe.computer/docs",
+ "openapi": "https://openvibe.computer/openapi.json",
+ "authentication": { "type": "bearer", "description": "A person's OpenVibe Network token; forwarded to Run." },
+ "capabilities": ["computers.list", "computers.create", "computers.read", "computers.stop", "computers.screenshot", "computers.act", "computers.handoff"]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+:root{--ink:#20222a;--muted:#667085;--paper:#f7f6f2;--panel:#fff;--line:#e2e2e6;--accent:#5846e8;--soft:#efedff;--good:#147d58}html{background:var(--paper);color:var(--ink);font-family:Inter,ui-sans-serif,system-ui,sans-serif}body{margin:0}.computer-page{box-sizing:border-box;max-width:1180px;margin:0 auto;padding:18px 24px 80px}.account-line{display:flex;justify-content:flex-end;min-height:24px;color:var(--muted);font-size:13px}.computer-page a{color:var(--accent);text-underline-offset:3px}.hero{display:grid;grid-template-columns:minmax(0,1.08fr) minmax(260px,.92fr);gap:40px;align-items:center;padding:68px 0 80px}.hero h1,.page-intro h1{font-size:clamp(2.3rem,5vw,4rem);line-height:1.03;letter-spacing:-.055em;max-width:720px;margin:12px 0 20px}.hero p,.page-intro p{max-width:660px;color:var(--muted);font-size:1.08rem;line-height:1.65}.eyebrow{font-size:.72rem;font-weight:800;letter-spacing:.12em;color:var(--accent)}.button-primary,.button-secondary,.button-danger,.button-quiet{display:inline-flex;min-height:44px;align-items:center;justify-content:center;box-sizing:border-box;padding:0 18px;border:1px solid transparent;border-radius:12px;font-weight:700;text-decoration:none;cursor:pointer}.button-primary{background:var(--accent);color:white!important}.button-secondary{background:var(--panel);border-color:var(--line)}.button-danger{background:#a52f36;color:white}.button-quiet{background:var(--panel);border-color:var(--line);min-height:40px}.button-primary:disabled,.button-danger:disabled{opacity:.55;cursor:not-allowed}.hero-art{min-height:250px;display:grid;place-content:center;filter:drop-shadow(0 26px 34px #24205224)}.screen{width:min(390px,75vw);height:220px;border:11px solid #25263a;border-radius:22px;background:linear-gradient(140deg,#c9c1ff,#f5e9db 52%,#a6e1ce);overflow:hidden}.screen-top{height:25px;background:#292a40}.screen-content{padding:45px 34px;display:grid;gap:12px}.screen-content i{height:14px;border-radius:20px;background:#fff9}.screen-content i:nth-child(2){width:70%}.screen-content i:nth-child(3){width:45%}.screen-base{height:14px;width:110%;margin-left:-5%;border-radius:0 0 22px 22px;background:#45465d}.section-heading{display:flex;justify-content:space-between;align-items:end;gap:24px;margin-bottom:22px}.section-heading h2,.mode-panel h2,.timeline h2{margin:8px 0 0;letter-spacing:-.03em;font-size:1.8rem}.section-heading p{max-width:390px;color:var(--muted);line-height:1.55}.kind-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.kind-card,.template-card,.price-grid article,.sign-in-callout,.timeline,.mode-panel{background:var(--panel);border:1px solid var(--line);border-radius:18px;padding:22px;min-width:0}.kind-card h3,.template-card h2{margin:9px 0;font-size:1.25rem}.kind-card p,.template-card p{color:var(--muted);line-height:1.5}.kind-card .runs{font-size:.9rem}.kind-card .price{min-height:54px;font-size:.84rem;color:var(--ink)}.availability{display:inline-flex;padding:10px 0;color:var(--muted);font-size:.86rem}.your-computers,.sign-in-callout,#start{margin-top:74px}.computer-list{list-style:none;margin:0;padding:0;border-top:1px solid var(--line)}.computer-list li{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px 16px;align-items:center;padding:16px 4px;border-bottom:1px solid var(--line)}.computer-list li>span,.computer-list li>form{color:var(--muted);font-size:.88rem}.computer-list li>form{grid-column:2;grid-row:1/3}.status{color:var(--good)!important}.page-intro{padding:50px 0 34px}.page-intro h1{font-size:clamp(2.2rem,4vw,3.4rem)}.template-grid,.price-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.template-mark{display:grid;place-items:center;width:38px;height:38px;border-radius:12px;background:var(--soft);color:var(--accent);font-weight:800}.price-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.price-grid h2{font-size:1.15rem}.price-grid p{line-height:1.55;color:var(--muted)}.fine-print{font-size:.86rem;line-height:1.5;color:var(--muted)}pre{white-space:pre-wrap;overflow-wrap:anywhere;padding:20px;background:#232438;color:#fff;border-radius:16px}.title-row{display:flex;justify-content:space-between;gap:16px;align-items:center}.screen-panel{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(290px,.7fr);gap:16px}.screen-placeholder{min-height:400px;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;border:1px dashed #b8b4d6;border-radius:18px;background:linear-gradient(140deg,#e7e4fb,#eff7f3);padding:26px}.screen-placeholder h2{margin:18px 0 6px}.screen-placeholder p{max-width:410px;color:var(--muted);line-height:1.5}.screen-led{width:12px;height:12px;border-radius:50%;background:#b98b27;box-shadow:0 0 0 7px #b98b271a}.mode-panel{display:flex;flex-direction:column}.mode-list{list-style:none;margin:16px 0;padding:0}.mode-list li{display:flex;justify-content:space-between;gap:8px;padding:12px 10px;border-radius:10px;border-bottom:1px solid var(--line);font-size:.88rem;line-height:1.35}.mode-list li.selected{background:var(--soft);color:var(--accent);font-weight:700}.mode-list li span{font-size:.72rem;color:var(--muted);white-space:nowrap}.timeline{margin-top:18px}.timeline p{color:var(--muted)}.sign-in-callout{padding:32px}@media(prefers-color-scheme:dark){:root{--ink:#f0eff6;--muted:#aaaaba;--paper:#11121a;--panel:#1b1c27;--line:#353642;--accent:#a89cff;--soft:#302b4a;--good:#79d4ac}.hero-art{filter:drop-shadow(0 26px 34px #0008)}.screen{border-color:#070812}.screen-top{background:#10101c}.screen-base{background:#262638}.screen-placeholder{background:linear-gradient(140deg,#29243e,#1d302d);border-color:#50496b}.button-secondary,.button-quiet{color:var(--ink)}}@media(max-width:760px){.computer-page{padding:12px 16px 56px}.hero{grid-template-columns:1fr;padding:44px 0 54px;gap:22px}.hero-art{min-height:180px;justify-self:center}.screen{height:160px;width:min(330px,76vw)}.kind-grid,.template-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.section-heading{display:block}.section-heading p{margin-bottom:0}.screen-panel{grid-template-columns:1fr}.screen-placeholder{min-height:260px}.title-row{align-items:flex-start;flex-direction:column}.price-grid{grid-template-columns:1fr}}@media(max-width:480px){.kind-grid,.template-grid{grid-template-columns:1fr}.computer-list li{grid-template-columns:minmax(0,1fr) auto}.computer-list li>span:nth-of-type(2){grid-column:1;overflow-wrap:anywhere}.computer-list li>form{grid-column:2;grid-row:1/4}.account-line{justify-content:flex-start}.button-primary,.button-secondary,.button-danger{max-width:100%}}
+
+/* Theme tokens (Shared presets: a person's theme and light/dark follow), over the first palette above. */
+:root{--ink:var(--text-primary,#e6edf7);--muted:var(--text-secondary,#96a7c2);--paper:var(--bg-primary,#0a0f1c);--panel:var(--bg-card,#131c2e);--line:var(--border,#26344d);--accent:var(--accent-light,#60a5fa);--soft:color-mix(in srgb,var(--accent,#3b82f6) 12%,transparent);--good:var(--success,#22c55e)}
+html{background:var(--paper);color:var(--ink)}
+/* The hero is words and two buttons now (the drawn screen was a picture of nothing) */
+.hero{grid-template-columns:1fr;max-width:760px}
+/* One price sentence for the whole page */
+.price-line{margin:-4px 0 16px;color:var(--muted);font-size:15px;line-height:1.5;max-width:70ch}
+/* The one kind you can start today, then the six that are coming, compact */
+.kind-grid:has(.kind-card--ready:only-child){grid-template-columns:minmax(0,560px)}
+.soon-h{margin:28px 0 10px;font-size:15px;font-weight:750;color:var(--ink)}
+.soon-h .muted{color:var(--muted);font-weight:600}
+.kind-soon-grid{list-style:none;margin:0;padding:0;display:grid;gap:10px;grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))}
+.kind-soon{display:grid;gap:3px;padding:12px 14px;border:1px dashed var(--line);border-radius:14px;background:color-mix(in srgb,var(--panel) 60%,transparent)}
+.kind-soon b{font-size:15px}
+.kind-soon span{font-size:13.5px;line-height:1.4;color:var(--muted)}
+.kind-soon small{font-size:12px;color:var(--muted);opacity:.85}
+@media (max-width:520px){.kind-soon-grid{grid-template-columns:1fr 1fr;gap:8px}.kind-soon{padding:10px 12px}}
+button[disabled][title]{cursor:not-allowed;opacity:.6}
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+# OpenVibe.Computer
+
+A computer for you, your AI, or both. OpenVibe.Computer is the front door and API facade; OpenVibe.Run owns sessions, budgets and placement.
+
+- [Home](/)
+- [Templates](/templates)
+- [Pricing](/pricing)
+- [Docs](/docs)
+- [Updates](/updates)
+- [OpenAPI](/openapi.json)
+- [Agent descriptor](/.well-known/agent.json)
+
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+{
+ "openapi": "3.1.0",
+ "info": { "title": "OpenVibe.Computer API", "version": "1.0.0", "description": "Thin facade for OpenVibe.Run sessions. The caller's bearer token is forwarded unchanged; Run enforces authorization, placement and budgets." },
+ "servers": [{ "url": "https://openvibe.computer/api/v1" }],
+ "security": [{ "bearerAuth": [] }],
+ "components": { "securitySchemes": { "bearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "OpenVibe Network user token" } }, "schemas": { "ComputerCreate": { "type": "object", "properties": { "kind": { "const": "browser" }, "width": { "type": "integer" }, "height": { "type": "integer" }, "ttl_s": { "type": "integer" }, "idle_s": { "type": "integer" }, "machine": { "type": "string" }, "requirements": { "type": "object" }, "idempotency_key": { "type": "string" } }, "required": ["kind"], "additionalProperties": false }, "Act": { "type": "object", "properties": { "kind": { "enum": ["screen", "input", "browser"] }, "value": { "type": "object" }, "id": { "type": "string" } }, "required": ["kind", "value"], "additionalProperties": false } } },
+ "paths": {
+ "/computers": {
+ "get": { "summary": "List your computers", "parameters": [{ "in": "query", "name": "state", "schema": { "enum": ["running", "ended", "all"] } }, { "in": "query", "name": "limit", "schema": { "type": "integer" } }], "responses": { "200": { "description": "Run session list" } } },
+ "post": { "summary": "Start a browser computer", "description": "Maps to Run POST /api/v1/sessions. Run currently accepts browser sessions only.", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ComputerCreate" } } } }, "responses": { "201": { "description": "Run session" }, "422": { "description": "Unsupported kind or invalid request" } } }
+ },
+ "/computers/{id}": { "parameters": [{ "in": "path", "name": "id", "required": true, "schema": { "type": "string" } }], "get": { "summary": "Get a computer", "responses": { "200": { "description": "Run session" } } } },
+ "/computers/{id}/stop": { "parameters": [{ "in": "path", "name": "id", "required": true, "schema": { "type": "string" } }], "post": { "summary": "Stop a computer", "responses": { "200": { "description": "Ended Run session" } } } },
+ "/computers/{id}/screenshot": { "parameters": [{ "in": "path", "name": "id", "required": true, "schema": { "type": "string" } }], "post": { "summary": "Capture a screen", "description": "Sends a Run screen command. This is a one off image command, not a live stream.", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { "max_width": { "type": "integer", "minimum": 320, "maximum": 1920 } } } } } }, "responses": { "200": { "description": "Run screen command result" } } } },
+ "/computers/{id}/act": { "parameters": [{ "in": "path", "name": "id", "required": true, "schema": { "type": "string" } }], "post": { "summary": "Send a computer command", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Act" } } } }, "responses": { "200": { "description": "Run command result" } } } },
+ "/computers/{id}/handoff": { "parameters": [{ "in": "path", "name": "id", "required": true, "schema": { "type": "string" } }], "post": { "summary": "Hand controls to the person", "description": "Reserved facade operation. Returns 501 until Run supports B3 control modes and handoff.", "responses": { "501": { "description": "Run handoff is not available yet" } } } }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+'use strict';
+const express = require('express');
+
+function createApi({ run, sso }) {
+ const r = express.Router();
+ r.use(express.json({ limit: '64kb' }));
+ const token = (req, res) => {
+ const value = sso.extractToken(req);
+ if (!value) { res.status(401).type('application/problem+json').json({ type: 'about:blank', title: 'Sign in required', status: 401, code: 'computer.auth.required' }); return null; }
+ return value;
+ };
+ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch((e) => {
+ if (res.headersSent) return next(e);
+ const status = Number(e.status) || 502;
+ res.status(status).type('application/problem+json').json({ type: 'about:blank', title: e.message || 'Run request failed', status, code: e.code || 'computer.run_error' });
+ });
+ r.get('/computers', wrap(async (req, res) => { const t = token(req, res); if (!t) return; res.json(await run.list(t, `?state=${encodeURIComponent(req.query.state || 'all')}&limit=${encodeURIComponent(req.query.limit || '20')}`)); }));
+ r.post('/computers', wrap(async (req, res) => {
+ const t = token(req, res); if (!t) return;
+ if (req.body?.kind && req.body.kind !== 'browser') return res.status(422).json({ code: 'computer.kind.unsupported', detail: 'Run currently creates browser sessions only.' });
+ const { template, kind, ...runBody } = req.body || {};
+ if (template) return res.status(501).json({ code: 'computer.template.pending', detail: 'Run does not accept templates yet.' });
+ res.status(201).json(await run.create(t, { kind: 'browser', ...runBody }));
+ }));
+ r.get('/computers/:id', wrap(async (req, res) => { const t = token(req, res); if (!t) return; res.json(await run.get(t, req.params.id)); }));
+ r.post('/computers/:id/stop', wrap(async (req, res) => { const t = token(req, res); if (!t) return; res.json(await run.stop(t, req.params.id)); }));
+ r.post('/computers/:id/screenshot', wrap(async (req, res) => {
+ const t = token(req, res); if (!t) return;
+ const width = Math.min(1920, Math.max(320, Number(req.body?.max_width) || 1280));
+ res.json(await run.command(t, req.params.id, { kind: 'screen', value: { max_width: width } }));
+ }));
+ r.post('/computers/:id/act', wrap(async (req, res) => {
+ const t = token(req, res); if (!t) return;
+ const { kind, value, id } = req.body || {};
+ if (!['screen', 'input', 'browser'].includes(kind) || !value || typeof value !== 'object' || Array.isArray(value)) return res.status(400).json({ code: 'computer.action.invalid', detail: 'Send a Run screen, input or browser command.' });
+ res.json(await run.command(t, req.params.id, { kind, value, ...(id ? { id } : {}) }));
+ }));
+ r.post('/computers/:id/handoff', wrap(async (req, res) => {
+ const t = token(req, res); if (!t) return;
+ await run.get(t, req.params.id);
+ res.status(501).type('application/problem+json').json({ type: 'about:blank', title: 'Run handoff unavailable', status: 501, code: 'computer.handoff.pending', detail: 'Run does not expose B3 control modes or a handoff route yet.' });
+ }));
+ return r;
+}
+module.exports = { createApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+'use strict';
+const path = require('path');
+const express = require('express');
+const helmet = require('helmet');
+const { createSsoClient } = require('openvibe-sdk/sso');
+const { createRunClient } = require('./run-client');
+const { createApi } = require('./api');
+const { createPages } = require('./pages');
+const configLib = require('./config');
+
+function createApp(opts = {}) {
+ const config = opts.config || configLib.load();
+ const sso = opts.sso || createSsoClient({ site: 'computer', baseUrl: config.baseUrl, clientId: config.clientId, clientSecret: config.clientSecret, networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl, secureCookies: config.secureCookies });
+ const run = opts.run || createRunClient({ baseUrl: config.runUrl, fetchImpl: opts.fetchImpl });
+ const app = express();
+ app.disable('x-powered-by'); app.set('trust proxy', config.trustProxy);
+ app.use(helmet({ contentSecurityPolicy: { directives: {
+ defaultSrc: ["'self'"], scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network'], styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com'], fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'], imgSrc: ["'self'", 'data:', 'https://openvibe.network'], connectSrc: ["'self'", 'https://openvibe.network', 'https://openvibe.chat', 'https://billing.openvibe.network', 'https://openvibe.events'], frameSrc: ["'self'", 'https://openvibe.network'], frameAncestors: ["'none'"], objectSrc: ["'none'"], baseUri: ["'self'"], formAction: ["'self'", 'https://openvibe.network'],
+ } }, crossOriginEmbedderPolicy: false, referrerPolicy: { policy: 'strict-origin-when-cross-origin' } }));
+ app.use(express.urlencoded({ extended: false }));
+ app.use('/auth', sso.router(express));
+ app.use(sso.optionalAuth());
+ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-computer', version: require('../package.json').version }));
+ app.get('/robots.txt', (_req, res) => res.type('text/plain').send('User-agent: *\nAllow: /\nDisallow: /api/\nSitemap: https://openvibe.computer/sitemap.xml\n'));
+ app.get('/sitemap.xml', (_req, res) => res.type('application/xml').send('<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><url><loc>https://openvibe.computer/</loc></url><url><loc>https://openvibe.computer/templates</loc></url><url><loc>https://openvibe.computer/pricing</loc></url><url><loc>https://openvibe.computer/docs</loc></url><url><loc>https://openvibe.computer/updates</loc></url></urlset>'));
+ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'computer', service: 'computer', host: 'openvibe.computer', name: 'OpenVibe.Computer', profile: 'hosting' })); }
+ app.get('/llms.txt', (_req, res) => res.type('text/plain').send('# OpenVibe.Computer\n\nA computer for you, your AI, or both. Run owns sessions, placement and budgets.\n\n- [Templates](/templates)\n- [Pricing](/pricing)\n- [API docs](/docs)\n- [OpenAPI](/openapi.json)\n- [Agent descriptor](/.well-known/agent.json)\n'));
+ app.get('/openapi.json', (_req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'openapi.json')));
+ app.get('/.well-known/agent.json', (_req, res) => res.sendFile(path.join(__dirname, '..', 'public', '.well-known', 'agent.json')));
+ app.use('/api/v1', createApi({ run, sso }));
+ app.use('/shared', require('openvibe-shared/serve').handler());
+ app.use(express.static(path.join(__dirname, '..', 'public'), { index: false, maxAge: '1h' }));
+ app.use(createPages({ config, run, sso }));
+ app.use((err, _req, res, _next) => { if (res.headersSent) return; res.status(err.status || 500).type('text/plain').send('Computer could not complete that request.'); });
+ return { app, config, run, sso };
+}
+module.exports = { createApp };
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+'use strict';
+require('dotenv').config();
+const trim = (s) => String(s || '').replace(/\/+$/, '');
+const numberOrNull = (v) => v === '' || v == null || !Number.isFinite(Number(v)) || Number(v) < 0 ? null : Number(v);
+function load(env = process.env) {
+ const port = Number.parseInt(env.PORT, 10) || 5135;
+ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
+ return {
+ port, host: env.HOST || '127.0.0.1', baseUrl: trim(env.BASE_URL || `http://localhost:${port}`),
+ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
+ networkUrl, networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
+ runUrl: trim(env.OV_RUN_URL || 'https://openvibe.run'),
+ clientId: env.OV_OAUTH_CLIENT_ID || 'computer', clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
+ browserFreeMinutes: numberOrNull(env.COMPUTER_BROWSER_FREE_MINUTES),
+ desktopFreeMinutes: numberOrNull(env.COMPUTER_DESKTOP_FREE_MINUTES),
+ pricePerMinute: numberOrNull(env.COMPUTER_PRICE_PER_MINUTE),
+ secureCookies: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : env.NODE_ENV === 'production',
+ };
+}
+module.exports = { load };
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+'use strict';
+const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
+module.exports = { esc };
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
+'use strict';
+const { createApp } = require('./app');
+const { app, config } = createApp();
+app.listen(config.port, config.host, () => console.log(`[Computer] listening on ${config.host}:${config.port}`));
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+'use strict';
+const shell = require('openvibe-shared/shell');
+const serve = require('openvibe-shared/serve');
+const { esc } = require('./html');
+const NAV = [
+ { label: 'Home', href: '/' }, { label: 'Templates', href: '/templates' }, { label: 'Pricing', href: '/pricing' },
+ { label: 'Docs', href: '/docs' }, { label: 'Updates', href: '/updates' },
+];
+function page({ title, body, viewer, config, path = '/' }) {
+ const nav = { service: 'computer', apiBase: config.networkUrl, links: NAV.map((x) => ({ ...x, active: path === x.href })), history: { type: 'page', title }, silentLogin: `${config.baseUrl}/auth/login?silent=1&next={url}`, sessionUrl: '/auth/me', loginUrl: '/auth/login?next={path}', logoutUrl: '/auth/logout?next={path}' };
+ const footer = { service: 'computer', variant: 'full', brandName: 'OpenVibe.Computer' };
+ return shell.page({ name: 'OpenVibe.Computer', lang: 'en', title: `${title} · OpenVibe.Computer`, titleSuffix: '', siteName: 'OpenVibe.Computer', description: 'A computer for you, your AI, or both.', canonical: `${config.baseUrl}${path}`, robots: 'index, follow', home: '/', navLinks: NAV, navbar: nav, footer,
+ head: `<link rel="stylesheet" href="${serve.url('kit.css')}"><link rel="stylesheet" href="/css/computer.css"><meta name="referrer" content="strict-origin-when-cross-origin">`,
+ body: `<div id="navbar-mount"></div><main id="main" class="computer-page">${body}</main>` });
+}
+function send(res, opts, status = 200) { res.set('Cache-Control', opts.viewer?.kind === 'user' ? 'private, no-store' : 'public, max-age=60'); res.set('Vary', 'Cookie'); res.status(status).type('html').send(page(opts)); }
+module.exports = { page, send, NAV };
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+'use strict';
+const express = require('express');
+const crypto = require('crypto');
+const { esc } = require('./html');
+const { send } = require('./layout');
+const { priceText, freeText, kinds, templates, priceLine } = require('./product');
+
+function createPages({ config, run, sso }) {
+ const r = express.Router();
+ const viewerOf = (req) => req.user || req.viewer || { kind: 'anonymous' };
+ const tokenOf = (req) => sso.extractToken(req);
+ const card = (k) => `<article class="kind-card kind-card--ready"><span class="eyebrow">${esc(k.badge)}</span><h3>${esc(k.name)}</h3><p>${esc(k.for)}</p><p class="runs">Runs on ${esc(k.runs)}</p><form method="post" action="/start"><input type="hidden" name="kind" value="${esc(k.id)}"><button class="button-primary" type="submit">Start a ${esc(k.name.toLowerCase())}</button></form></article>`;
+ const soon = (k) => `<li class="kind-soon"><b>${esc(k.name)}</b><span>${esc(k.for)}</span><small>${esc(k.runs.replace(/\.$/, ''))}</small></li>`;
+ r.get('/', async (req, res) => {
+ const viewer = viewerOf(req); let sessions = [];
+ if (viewer.kind === 'user') { try { sessions = (await run.list(tokenOf(req), '?state=all&limit=8')).sessions || []; } catch { /* show dashboard state without hiding the service page */ } }
+ const computerRows = sessions.map((s) => `<li><a href="/computers/${encodeURIComponent(s.id)}"><strong>${esc(s.kind === 'browser' ? 'Browser computer' : s.kind)}</strong></a><span class="status">${esc(s.state)}</span><span>${esc(s.id)}</span><a href="/computers/${encodeURIComponent(s.id)}">Open</a>${s.state !== 'ended' ? `<form method="post" action="/computers/${encodeURIComponent(s.id)}/stop"><button class="button-quiet">Stop</button></form>` : ''}</li>`).join('');
+ const body = `<section class="hero"><div><span class="eyebrow">YOUR COMPUTER, WHEREVER YOU ARE</span><h1>A computer for you, your AI, or both.</h1><p>Start a browser desk on OpenVibe Run. Choose where it runs, keep control close, and bring your own Link machine for free use.</p><a class="button-primary" href="#start">Start a computer</a> <a class="button-secondary" href="/templates">Explore templates</a></div></section>
+ <section id="start"><div class="section-heading"><div><span class="eyebrow">PICK A COMPUTER</span><h2>Start a computer</h2></div><p>Each session runs where its kind is supported. Run chooses placement and enforces budgets.</p></div><p class="price-line">${esc(priceLine(config))} <a href="/pricing">Pricing</a></p><div class="kind-grid">${kinds(config).filter((k) => k.available).map(card).join('')}</div>
+ <h3 class="soon-h">Coming as Run adds them <span class="muted">${kinds(config).filter((k) => !k.available).length}</span></h3><ul class="kind-soon-grid">${kinds(config).filter((k) => !k.available).map(soon).join('')}</ul></section>
+ ${viewer.kind === 'user' ? `<section class="your-computers"><div class="section-heading"><div><span class="eyebrow">YOUR WORKSPACE</span><h2>Your computers</h2></div><a href="/api/v1/computers">Open API</a></div>${computerRows ? `<ul class="computer-list">${computerRows}</ul>` : '<p>No running or recent computers yet. Start a browser computer to see it here.</p>'}</section>` : `<section class="sign-in-callout"><h2>Keep your computers close</h2><p>Sign in to open, stop and manage your sessions.</p><a class="button-secondary" href="/auth/login?next=%2F">Sign in with OpenVibe</a></section>`}`;
+ send(res, { title: 'A computer for you, your AI, or both', body, viewer, config, path: '/' });
+ });
+ r.post('/start', express.urlencoded({ extended: false }), async (req, res) => {
+ const kind = String(req.body.kind || 'browser');
+ if (kind !== 'browser') return res.redirect(303, '/#start');
+ if (viewerOf(req).kind !== 'user') return res.redirect(303, `/auth/login?next=${encodeURIComponent('/')}`);
+ const viewer = viewerOf(req);
+ try { const session = await run.create(tokenOf(req), { kind: 'browser', idempotency_key: `computer:${crypto.randomUUID()}` }); return res.redirect(303, `/computers/${encodeURIComponent(session.id)}`); }
+ catch (e) { send(res, { title: 'Could not start computer', body: `<section class="page-intro"><h1>Could not start this computer</h1><p>${esc(e.message)}</p><a href="/#start">Try again</a></section>`, viewer, config, path: '/' }, e.status || 503); }
+ });
+ r.get('/computers/:id', async (req, res) => {
+ const viewer = viewerOf(req); if (viewer.kind !== 'user') return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
+ try {
+ const session = await run.get(tokenOf(req), req.params.id);
+ const modes = ['You drive, your AI watches', 'Your AI drives, you watch', 'Your AI drives and asks first', 'Together', 'Guests watch', 'Paused'];
+ const body = `<section class="page-intro"><a href="/">← Your computers</a><div class="title-row"><div><span class="eyebrow">${esc(session.state)} · ${esc(session.id)}</span><h1>Browser computer</h1></div><form method="post" action="/computers/${encodeURIComponent(session.id)}/stop"><button class="button-danger" ${session.state === 'ended' ? 'disabled' : ''}>Stop computer</button></form></div><p>Placed by OpenVibe Run. ${esc(session.placement?.machine || 'Run is choosing a machine')} · ${esc(session.usage?.desk_seconds || 0)} seconds used.</p></section>
+ <section class="screen-panel"><div class="screen-placeholder"><span class="screen-led"></span><h2>Live screen connects through Run</h2><p>Run currently supports one off screen commands. Its live desk stream is not exposed yet.</p></div><div class="mode-panel"><span class="eyebrow">WHO HOLDS THE CONTROLS</span><h2>Control mode</h2><ul class="mode-list">${modes.map((m) => `<li>${esc(m)} <span>Planned</span></li>`).join('')}</ul><form method="post" action="/computers/${encodeURIComponent(session.id)}/handoff"><button class="button-primary" disabled title="Arrives with Run's control lease">Take over</button></form><p class="fine-print">Run does not expose a control handoff or B3 mode yet. This request checks the session with Run and makes no change until Run adds that support.</p></div></section><section class="timeline"><span class="eyebrow">ACTIVITY</span><h2>Action timeline</h2><p>Run does not expose a session action timeline yet.</p><button class="button-secondary" disabled>Snapshot · coming when Run supports it</button></section>`;
+ send(res, { title: 'Your browser computer', body, viewer, config, path: req.path });
+ } catch (e) { send(res, { title: 'Computer unavailable', body: `<section class="page-intro"><h1>Computer unavailable</h1><p>${esc(e.message)}</p><a href="/">Back to your computers</a></section>`, viewer, config, path: req.path }, e.status || 503); }
+ });
+ r.post('/computers/:id/stop', async (req, res) => { try { await run.stop(tokenOf(req), req.params.id); res.redirect(303, '/'); } catch (e) { send(res, { title: 'Could not stop computer', body: `<h1>Could not stop computer</h1><p>${esc(e.message)}</p><a href="/">Back</a>`, viewer: viewerOf(req), config }, e.status || 503); } });
+ r.post('/computers/:id/handoff', async (req, res) => {
+ const viewer = viewerOf(req); if (viewer.kind !== 'user') return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
+ try { await run.get(tokenOf(req), req.params.id); send(res, { title: 'Take over', body: '<section class="page-intro"><h1>Take over is waiting on Run</h1><p>Run does not expose the B3 control lease yet, so this session was not changed.</p><a href="/computers">Back to computers</a></section>', viewer, config }); }
+ catch (e) { send(res, { title: 'Could not take over', body: `<h1>Could not take over</h1><p>${esc(e.message)}</p>`, viewer, config }, e.status || 503); }
+ });
+ r.get('/templates', (_req, res) => send(res, { title: 'Computer templates', body: `<section class="page-intro"><span class="eyebrow">ONE CLICK SETUPS</span><h1>Templates</h1><p>Choose a starting point for the computer you need. Run's current session API starts browser desks; templates wait on Run and the broker.</p></section><div class="template-grid">${templates.map((t) => `<article class="template-card"><span class="template-mark">${esc(t.mark)}</span><h2>${esc(t.name)}</h2><p>${esc(t.text)}</p><span class="availability">Coming as Run adds template support</span></article>`).join('')}</div>`, viewer: viewerOf(_req), config, path: '/templates' }));
+ r.get('/pricing', (_req, res) => send(res, { title: 'Pricing', body: `<section class="page-intro"><span class="eyebrow">CLEAR BEFORE YOU START</span><h1>Pricing</h1><p>Run enforces the session budget and placement. Prices become final when configured by the owner.</p></section><div class="price-grid"><article><h2>Your Link machines</h2><p>Free, with no time limit.</p></article><article><h2>Browser allowance</h2><p>${freeText(config.browserFreeMinutes, '20 hours per month (proposed)')}</p></article><article><h2>Desktop allowance</h2><p>${freeText(config.desktopFreeMinutes, '1 hour per month (proposed)')}</p></article><article><h2>After the allowance</h2><p>${priceText(config.pricePerMinute)}</p></article></div><p class="fine-print">The free allowances and per-minute price come from service configuration. Values marked proposed are not yet owner-set.</p>`, viewer: viewerOf(_req), config, path: '/pricing' }));
+ r.get('/docs', (_req, res) => send(res, { title: 'Computer API docs', body: `<section class="page-intro"><span class="eyebrow">BUILD ON COMPUTER</span><h1>API documentation</h1><p>Computer forwards each signed-in person's Network token to Run. Run owns budgets, placement and session state.</p><p><a href="/openapi.json">OpenAPI 3.1</a> · <a href="/.well-known/agent.json">Agent descriptor</a></p></section><pre>GET /api/v1/computers\nPOST /api/v1/computers\nGET /api/v1/computers/{id}\nPOST /api/v1/computers/{id}/stop\nPOST /api/v1/computers/{id}/screenshot\nPOST /api/v1/computers/{id}/act\nPOST /api/v1/computers/{id}/handoff</pre><p>Use a user Network token with the matching Run session capability. Computer does not use a service token on the person's behalf.</p>`, viewer: viewerOf(_req), config, path: '/docs' }));
+ r.get('/updates', (_req, res) => send(res, { title: 'Updates', body: '<section class="page-intro"><span class="eyebrow">UPDATES</span><h1>Computer is taking shape</h1><p>The first front door uses Run browser sessions. Desktop, GPU and always-on sessions follow as placement and templates arrive in Run.</p></section>', viewer: viewerOf(_req), config, path: '/updates' }));
+ return r;
+}
+module.exports = { createPages };
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+'use strict';
+const minutes = (n) => `${Math.floor(n / 60)} ${Math.floor(n / 60) === 1 ? 'hour' : 'hours'}${n % 60 ? ` ${n % 60} minutes` : ''} per month`;
+const freeText = (value, proposed) => value == null ? `Not configured · ${proposed}` : `${minutes(value)} included`;
+const priceText = (value) => value == null ? 'Not configured yet · proposed per-minute billing will be shown before a paid session starts.' : `$${value.toFixed(4)} per minute`;
+function kinds(config) {
+ const configuredBrowser = freeText(config.browserFreeMinutes, '20 hours monthly allowance proposed');
+ const price = priceText(config.pricePerMinute);
+ return [
+ { id: 'browser', name: 'Browser', badge: 'QUICK TASKS', for: 'A private browser for a task, an AI errand, or a shared screen.', runs: 'OpenVibe workers or your Link machines.', price: `Your Link machine: free · Cloud: ${configuredBrowser}; then ${price}`, available: true },
+ { id: 'kept-browser', name: 'Kept browser', badge: 'YOUR SITES', for: 'A browser that stays signed in with an encrypted profile.', runs: 'OpenVibe workers or your Link machines.', price: `Your Link machine: free · Cloud: ${configuredBrowser}; then ${price}`, available: false },
+ { id: 'desktop', name: 'Desktop', badge: 'FULL APPS', for: 'A full Linux desktop for apps and longer work.', runs: 'OpenVibe workers or Link.', price: `Your Link machine: free · ${freeText(config.desktopFreeMinutes, '1 free hour proposed')}; then ${price}`, available: false },
+ { id: 'gpu-desktop', name: 'GPU desktop', badge: 'RENDER & PLAY', for: 'A graphics desktop for Blender, video, local models and games.', runs: 'Home GPU, rented GPU or a Link GPU.', price: 'GPU cost and allowance not configured · proposed', available: false },
+ { id: 'always-on', name: 'Always-on', badge: 'FOR YOUR AGENT', for: 'A computer that stays ready between agent tasks.', runs: 'OpenVibe workers.', price: `Cloud: ${price}`, available: false },
+ { id: 'your-computer', name: 'Your computer', badge: 'FROM ANYWHERE', for: 'Reach your own computer from anywhere.', runs: 'Link with Desktop capability.', price: 'Free on your own Link machine.', available: false },
+ { id: 'throwaway', name: 'Throwaway', badge: 'NOTHING KEPT', for: 'A temporary computer that is wiped when it ends.', runs: 'OpenVibe workers.', price: `Cloud: ${configuredBrowser}; then ${price}`, available: false },
+ ];
+}
+const templates = [
+ { mark: '01', name: 'Streaming box', text: 'OBS ready for a stream to OpenVibe Live.' },
+ { mark: '02', name: 'Studio box', text: 'A home for the OpenVibe creative apps.' },
+ { mark: '03', name: 'Code box', text: 'An IDE alongside your OpenVibe Codes work.' },
+ { mark: '04', name: 'Blender box', text: 'A rendering setup with the right GPU.' },
+ { mark: '05', name: 'Games box', text: 'A computer set up for games and a shared screen.' },
+ { mark: '06', name: 'Research box', text: 'A browser with your Vibe in the Dock.' },
+];
+/** The whole price in one sentence (it was repeated, half-configured, on every card). */
+function priceLine(config) {
+ const browser = config.browserFreeMinutes == null ? '20 hours of cloud browser a month (proposed)' : `${minutes(config.browserFreeMinutes)} of cloud browser`;
+ const after = config.pricePerMinute == null ? 'then per minute at a price shown before any paid session starts' : `then $${config.pricePerMinute.toFixed(4)} a minute`;
+ return `Free and unlimited on your own Link machines. In the cloud: ${browser}, ${after}.`;
+}
+module.exports = { kinds, templates, freeText, priceText, priceLine };
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+'use strict';
+class RunError extends Error {
+ constructor(status, body) { super(body.detail || body.title || `Run returned ${status}`); this.status = status; this.code = body.code || 'run.upstream_error'; this.body = body; }
+}
+
+function createRunClient({ baseUrl, fetchImpl = globalThis.fetch }) {
+ async function request(token, path, { method = 'GET', body } = {}) {
+ const res = await fetchImpl(`${baseUrl}/api/v1${path}`, {
+ method, headers: { authorization: `Bearer ${token}`, accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}) },
+ ...(body ? { body: JSON.stringify(body) } : {}),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) throw new RunError(res.status, data);
+ return data;
+ }
+ return {
+ list: (token, query = '') => request(token, `/sessions${query}`),
+ get: (token, id) => request(token, `/sessions/${encodeURIComponent(id)}`),
+ create: (token, body) => request(token, '/sessions', { method: 'POST', body }),
+ stop: (token, id) => request(token, `/sessions/${encodeURIComponent(id)}/end`, { method: 'POST', body: {} }),
+ command: (token, id, body) => request(token, `/sessions/${encodeURIComponent(id)}/commands`, { method: 'POST', body }),
+ };
+}
+module.exports = { createRunClient, RunError };
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+'use strict';
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const http = require('node:http');
+const express = require('express');
+const { createApp } = require('../server/app');
+const { createRunClient } = require('../server/run-client');
+
+const config = { port: 0, host: '127.0.0.1', baseUrl: 'http://computer.test', networkUrl: 'https://openvibe.network', runUrl: 'https://openvibe.run', browserFreeMinutes: null, desktopFreeMinutes: null, pricePerMinute: null };
+function fakeSso() {
+ return {
+ router: () => express.Router(),
+ optionalAuth: () => (req, _res, next) => { if ((req.headers.cookie || '').includes('ov_token=person-a')) req.user = { kind: 'user', sub: 'usr_a', displayName: 'Person A' }; next(); },
+ extractToken: (req) => { const auth = req.headers.authorization || ''; if (auth.startsWith('Bearer ')) return auth.slice(7); const m = (req.headers.cookie || '').match(/(?:^|;\s*)ov_token=([^;]+)/); return m && m[1] || null; },
+ };
+}
+async function serve(opts) {
+ const { app } = createApp({ config, sso: fakeSso(), ...opts });
+ const server = http.createServer(app); await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ return { base: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) };
+}
+
+test('home renders signed out and signed in; sign-in and computer controls are visible', async (t) => {
+ const calls = [];
+ const run = { list: async (token) => { calls.push(['list', token]); return { sessions: [{ id: 'ses_a', kind: 'browser', state: 'running', usage: { desk_seconds: 12 } }] }; }, get: async (token, id) => { calls.push(['get', token, id]); return { id, kind: 'browser', state: 'running', usage: { desk_seconds: 12 } }; }, stop: async () => ({}) };
+ const s = await serve({ run }); t.after(s.close);
+ const guest = await fetch(s.base); assert.equal(guest.status, 200); assert.match(await guest.text(), /Sign in with OpenVibe/);
+ const home = await fetch(s.base, { headers: { cookie: 'ov_token=person-a' } }); const html = await home.text();
+ assert.match(html, /Your computers/); assert.match(html, /Browser/); assert.match(html, /20 hours of cloud browser a month \(proposed\)/); assert.match(html, /Coming as Run adds them/); assert.doesNotMatch(html, /hero-art|account-line/);
+ const detail = await fetch(`${s.base}/computers/ses_a`, { headers: { cookie: 'ov_token=person-a' } }); const page = await detail.text();
+ for (const mode of ['You drive, your AI watches', 'Your AI drives, you watch', 'Your AI drives and asks first', 'Together', 'Guests watch', 'Paused']) assert.match(page, new RegExp(mode));
+ assert.match(page, /Take over/); assert.equal(calls[0][1], 'person-a'); assert.deepEqual(calls[1], ['get', 'person-a', 'ses_a']);
+ for (const path of ['/templates', '/pricing', '/docs', '/updates']) { const r = await fetch(`${s.base}${path}`); assert.equal(r.status, 200, `${path} renders signed out`); }
+ for (const path of ['/templates', '/pricing', '/docs', '/updates']) { const r = await fetch(`${s.base}${path}`, { headers: { cookie: 'ov_token=person-a' } }); assert.equal(r.status, 200, `${path} renders signed in`); }
+ assert.equal((await fetch(`${s.base}/openapi.json`)).status, 200); assert.equal((await fetch(`${s.base}/.well-known/agent.json`)).status, 200); assert.equal((await fetch(`${s.base}/llms.txt`)).status, 200);
+});
+
+test('facade forwards only the presented person token to Run', async (t) => {
+ const received = [];
+ const run = { list: async (token) => { received.push(token); return { sessions: [] }; }, create: async (token, body) => { received.push(token); return { id: 'ses_new', ...body }; } };
+ const s = await serve({ run }); t.after(s.close);
+ assert.equal((await fetch(`${s.base}/api/v1/computers`)).status, 401);
+ const list = await fetch(`${s.base}/api/v1/computers`, { headers: { authorization: 'Bearer user-token-A' } }); assert.equal(list.status, 200);
+ const create = await fetch(`${s.base}/api/v1/computers`, { method: 'POST', headers: { authorization: 'Bearer user-token-B', 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'browser' }) }); assert.equal(create.status, 201);
+ assert.deepEqual(received, ['user-token-A', 'user-token-B']);
+});
+
+test('starting from the home page is a signed-in POST to Run', async (t) => {
+ const calls = []; const run = { create: async (token, body) => { calls.push([token, body]); return { id: 'ses_started' }; } };
+ const s = await serve({ run }); t.after(s.close);
+ const guest = await fetch(`${s.base}/start`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: 'kind=browser', redirect: 'manual' });
+ assert.equal(guest.status, 303); assert.match(guest.headers.get('location'), /auth\/login/); assert.equal(calls.length, 0);
+ const signed = await fetch(`${s.base}/start`, { method: 'POST', headers: { cookie: 'ov_token=person-a', 'content-type': 'application/x-www-form-urlencoded' }, body: 'kind=browser', redirect: 'manual' });
+ assert.equal(signed.status, 303); assert.equal(signed.headers.get('location'), '/computers/ses_started');
+ assert.equal(calls[0][0], 'person-a'); assert.equal(calls[0][1].kind, 'browser'); assert.match(calls[0][1].idempotency_key, /^computer:/);
+});
+
+test('screen, act and stop map to Run; handoff reports unsupported Run capability', async (t) => {
+ const calls = []; const run = { command: async (token, id, body) => { calls.push([token, id, body]); return { result: { outcome: 'ack' } }; }, stop: async (token, id) => { calls.push([token, id, 'stop']); return { state: 'ended' }; }, get: async (token, id) => { calls.push([token, id, 'get']); return { id, state: 'running' }; } };
+ const s = await serve({ run }); t.after(s.close); const headers = { authorization: 'Bearer user-token-A', 'content-type': 'application/json' };
+ assert.equal((await fetch(`${s.base}/api/v1/computers/ses_a/screenshot`, { method: 'POST', headers, body: '{}' })).status, 200);
+ assert.equal((await fetch(`${s.base}/api/v1/computers/ses_a/act`, { method: 'POST', headers, body: JSON.stringify({ kind: 'browser', value: { action: 'read' } }) })).status, 200);
+ assert.equal((await fetch(`${s.base}/api/v1/computers/ses_a/stop`, { method: 'POST', headers, body: '{}' })).status, 200);
+ const handoff = await fetch(`${s.base}/api/v1/computers/ses_a/handoff`, { method: 'POST', headers, body: '{}' }); assert.equal(handoff.status, 501);
+ assert.equal(calls[0][2].kind, 'screen'); assert.equal(calls[1][2].kind, 'browser'); assert.deepEqual(calls[2], ['user-token-A', 'ses_a', 'stop']); assert.deepEqual(calls[3], ['user-token-A', 'ses_a', 'get']);
+});
+
+test('Run client sends the original bearer token on each upstream request', async () => {
+ const seen = []; const client = createRunClient({ baseUrl: 'https://run.example', fetchImpl: async (url, options) => { seen.push([url, options.headers.authorization, options.method]); return { ok: true, json: async () => ({ sessions: [] }) }; } });
+ await client.list('one-token'); await client.get('another-token', 'ses_abc');
+ assert.deepEqual(seen, [['https://run.example/api/v1/sessions', 'Bearer one-token', 'GET'], ['https://run.example/api/v1/sessions/ses_abc', 'Bearer another-token', 'GET']]);
+});