openvibers/openvibe.computer Commits

Build OpenVibe Computer front door (#1)

OP OpenVibers committed 5 days ago · bd4c936ea6 · 23 files, +1960 −1

* Build OpenVibe Computer front door

* Computer: one price sentence, the kind you can start and the six coming as tiles, no drawn screen, theme tokens, Take over disabled until Run has the lease

Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..0aeeff8
---- /dev/null
++++ b/.env.example
@@@ -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=
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..96afeb7
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -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
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..36c1607
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,8 @@
++node_modules/
++.env
++.env.*
++!.env.example
++*.log
++data/
++dist/
++
ddiff --git a/README.md b/README.md
iindex 05e871b..7bb06ee 100644
---- a/README.md
++++ b/README.md
@@@ -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.
ddiff --git a/deploy/LAUNCH.md b/deploy/LAUNCH.md
nnew file mode 100644
iindex 0000000..77d8fec
---- /dev/null
++++ b/deploy/LAUNCH.md
@@@ -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.
ddiff --git a/deploy/nginx/openvibe.computer.conf b/deploy/nginx/openvibe.computer.conf
nnew file mode 100644
iindex 0000000..d2d4fcb
---- /dev/null
++++ b/deploy/nginx/openvibe.computer.conf
@@@ -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; }
++}
ddiff --git a/deploy/systemd/openvibe-computer.service b/deploy/systemd/openvibe-computer.service
nnew file mode 100644
iindex 0000000..ce92a3c
---- /dev/null
++++ b/deploy/systemd/openvibe-computer.service
@@@ -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
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..2734e0d
---- /dev/null
++++ b/package-lock.json
@@@ -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"
++ }
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..1e76b4a
---- /dev/null
++++ b/package.json
@@@ -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"
++}
ddiff --git a/public/.well-known/agent.json b/public/.well-known/agent.json
nnew file mode 100644
iindex 0000000..fab5ad9
---- /dev/null
++++ b/public/.well-known/agent.json
@@@ -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"]
++}
ddiff --git a/public/css/computer.css b/public/css/computer.css
nnew file mode 100644
iindex 0000000..33dcad4
---- /dev/null
++++ b/public/css/computer.css
@@@ -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}
ddiff --git a/public/llms.txt b/public/llms.txt
nnew file mode 100644
iindex 0000000..b69c374
---- /dev/null
++++ b/public/llms.txt
@@@ -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)
++
ddiff --git a/public/openapi.json b/public/openapi.json
nnew file mode 100644
iindex 0000000..87b851c
---- /dev/null
++++ b/public/openapi.json
@@@ -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" } } } }
++ }
++}
ddiff --git a/server/api.js b/server/api.js
nnew file mode 100644
iindex 0000000..6c5714b
---- /dev/null
++++ b/server/api.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..1d38ebb
---- /dev/null
++++ b/server/app.js
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..1b0e030
---- /dev/null
++++ b/server/config.js
@@@ -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 };
ddiff --git a/server/html.js b/server/html.js
nnew file mode 100644
iindex 0000000..8d2f757
---- /dev/null
++++ b/server/html.js
@@@ -0,0 +1,3 @@
++'use strict';
++const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
++module.exports = { esc };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..3918c44
---- /dev/null
++++ b/server/index.js
@@@ -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}`));
ddiff --git a/server/layout.js b/server/layout.js
nnew file mode 100644
iindex 0000000..ea10dc4
---- /dev/null
++++ b/server/layout.js
@@@ -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 };
ddiff --git a/server/pages.js b/server/pages.js
nnew file mode 100644
iindex 0000000..fea2767
---- /dev/null
++++ b/server/pages.js
@@@ -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 };
ddiff --git a/server/product.js b/server/product.js
nnew file mode 100644
iindex 0000000..c72d5be
---- /dev/null
++++ b/server/product.js
@@@ -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 };
ddiff --git a/server/run-client.js b/server/run-client.js
nnew file mode 100644
iindex 0000000..b7f0656
---- /dev/null
++++ b/server/run-client.js
@@@ -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 };
ddiff --git a/test/computer.test.js b/test/computer.test.js
nnew file mode 100644
iindex 0000000..64a7aff
---- /dev/null
++++ b/test/computer.test.js
@@@ -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']]);
++});