openvibers/openvibe.studio Commits
OpenVibe.Studio v0: Photo and Light (#1)
Changes
nnew file mode 100644@@@ -0,0 +1,112 @@++name: bundles++# The app bundles are built here, from pinned upstream tags, checked by the first-frame smoke test++# and published as a release the host can fetch without a GitHub account. A bundle is never kept++# in git.++on:++ push:++ branches: [main]++ pull_request:++ paths:++ - "scripts/build-bundles.sh"++ - "scripts/smoke.js"++ - "server/bundle-files.js"++ - "server/bundles.js"++ workflow_dispatch:++permissions:++ contents: read++concurrency:++ group: bundles-${{ github.ref }}++ cancel-in-progress: true++jobs:++ build:++ runs-on: ubuntu-24.04++ timeout-minutes: 120++ 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: check out PhotoCraft++ uses: actions/checkout@v4++ with:++ repository: storytold/photocraft++ ref: v0.21.0++ path: vendor/photocraft++ - name: check out LightCraft++ uses: actions/checkout@v4++ with:++ repository: storytold/lightcraft++ ref: v0.19.0++ path: vendor/lightcraft++ - name: rust toolchain++ uses: dtolnay/rust-toolchain@stable++ with:++ targets: wasm32-unknown-unknown++ - name: trunk++ run: |++ curl -fsSL https://github.com/trunk-rs/trunk/releases/download/v0.21.14/trunk-x86_64-unknown-linux-gnu.tar.gz | tar -xz -C /usr/local/bin trunk++ trunk --version++ - name: wasm-bindgen++ run: |++ curl -fsSL https://github.com/rustwasm/wasm-bindgen/releases/download/0.2.129/wasm-bindgen-0.2.129-x86_64-unknown-linux-musl.tar.gz | tar -xz -C /tmp++ sudo cp /tmp/wasm-bindgen-0.2.129-x86_64-unknown-linux-musl/wasm-bindgen* /usr/local/bin/++ wasm-bindgen --version++ - name: build the bundles++ run: scripts/build-bundles.sh all++ - name: smoke test PhotoCraft++ run: node scripts/smoke.js --app photo --dir artifacts/photo++ - name: smoke test LightCraft++ run: node scripts/smoke.js --app light --dir artifacts/light++ - name: checksums++ run: |++ cd artifacts++ find . -type f -print0 | sort -z | xargs -0 sha256sum > bundles.sha256++ cat bundles.sha256++ - uses: actions/upload-artifact@v4++ with:++ name: studio-bundles++ path: |++ artifacts/photo++ artifacts/light++ artifacts/bundles.sha256++ retention-days: 30++++ # On main the built bundles become a release the host fetches with curl: no GitHub account and++ # no token are needed there. The assets of an existing release are replaced.++ publish:++ name: publish the bundles release++ if: github.event_name == 'push' && github.ref == 'refs/heads/main'++ needs: build++ runs-on: ubuntu-24.04++ permissions:++ contents: write++ steps:++ - uses: actions/checkout@v4++ - uses: actions/setup-node@v4++ with:++ node-version: 22.22.1++ - uses: actions/download-artifact@v4++ with:++ name: studio-bundles++ path: artifacts++ - name: pack and publish++ env:++ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}++ run: |++ set -euo pipefail++ PHOTO_TAG="$(node -e "process.stdout.write(require('./server/adapters/photo').base.tag)")"++ LIGHT_TAG="$(node -e "process.stdout.write(require('./server/adapters/light').base.tag)")"++ RELEASE="bundles-${PHOTO_TAG}-${LIGHT_TAG}"++ TARBALL="studio-bundles-${PHOTO_TAG}-${LIGHT_TAG}.tar.gz"++ cd artifacts++ tar -czf "$TARBALL" photo light++ sha256sum "$TARBALL" > SHA256SUMS++ cat SHA256SUMS++ if gh release view "$RELEASE" >/dev/null 2>&1; then++ echo "replacing the assets of $RELEASE"++ gh release upload "$RELEASE" "$TARBALL" SHA256SUMS --clobber++ else++ gh release create "$RELEASE" "$TARBALL" SHA256SUMS --title "$RELEASE" --notes "The app bundles for PhotoCraft ${PHOTO_TAG} and LightCraft ${LIGHT_TAG}. Check SHA256SUMS before unpacking; scripts/fetch-bundles.sh does that on the host."++ finnew file mode 100644@@@ -0,0 +1,23 @@++name: ci++on:++ push:++ branches: [main]++ pull_request:++ workflow_dispatch:++permissions:++ contents: read++jobs:++ test:++ runs-on: ubuntu-24.04++ steps:++ - uses: actions/checkout@v4++ - uses: actions/setup-node@v4++ with:++ node-version: 22.22.1++ cache: npm++ - run: npm ci --no-audit --no-fund++ - name: syntax++ run: find server test scripts public/js -name '*.js' -print0 | xargs -0 -n1 node --check++ - run: npm test++ security:++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176nnew file mode 100644@@@ -0,0 +1,33 @@++# Attribution++++OpenVibe.Studio hosts builds of open-source projects. Each build keeps its own licence and its++own attribution files, and the build artifacts carry them.++++## PhotoCraft++++- Project: <https://github.com/storytold/photocraft>++- Build: `v0.21.0` (the tag is pinned in `server/adapters/photo.js` and in `.github/workflows/bundle.yml`)++- Licence: MIT OR Apache-2.0, at your choice. The build keeps `LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE` and `ATTRIBUTION.md`.++- Built by: our CI, with Rust stable, the `wasm32-unknown-unknown` target and Trunk 0.21.14.++- Why our own build: the release archive of the web build does not start (the loader looks for its++ module under a path the archive does not have). The build in this repository is made from the++ pinned tag and is checked by a smoke test that fails unless the app reaches its first frame.++++## LightCraft++++- Project: <https://github.com/storytold/lightcraft>++- Build: `v0.19.0` (the tag is pinned in `server/adapters/light.js` and in `.github/workflows/bundle.yml`)++- Licence: MIT OR Apache-2.0, at your choice. The build keeps `LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE` and `ATTRIBUTION.md`.++- Built by: our CI, with Rust stable, the `wasm32-unknown-unknown` target, `cargo xtask web` and wasm-bindgen 0.2.129.++- The optional Chinese font (16 MB) is left out of our build, so Chinese and Japanese text has no++ glyphs. The two fonts the module always embeds are under the SIL Open Font License 1.1: BIZ++ UDPGothic and Noto Sans Thai. Their licence texts travel with the bundle.++++## OpenVibe.Studio++++The service in this repository is MIT licensed (see `LICENSE`). It is a separate work: it hosts a++build of each project under its own terms and adds the OpenVibe layer around it (the sign-in, the++files a person owns, and the way back out).++++The OpenVibe Shared kit and the OpenVibe SDK and Contracts packages are used as dependencies;++each keeps its own licence.nnew file mode 100644@@@ -0,0 +1,21 @@++MIT License++++Copyright (c) 2026 OpenVibers++++Permission is hereby granted, free of charge, to any person obtaining a copy++of this software and associated documentation files (the "Software"), to deal++in the Software without restriction, including without limitation the rights++to use, copy, modify, merge, publish, distribute, sublicense, and/or sell++copies of the Software, and to permit persons to whom the Software is++furnished to do so, subject to the following conditions:++++The above copyright notice and this permission notice shall be included in all++copies or substantial portions of the Software.++++THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR++IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,++FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE++AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER++LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,++OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE++SOFTWARE.nnew file mode 100644@@@ -0,0 +1,24 @@++OpenVibe.Studio++Copyright (c) 2026 OpenVibers++++This product includes software developed by the Storytold project:++++ PhotoCraft (https://github.com/storytold/photocraft), build v0.21.0++ LightCraft (https://github.com/storytold/lightcraft), build v0.19.0++++Both are licensed under either of++++ the MIT License, or++ the Apache License, Version 2.0++++at your option. The licence files of each build (LICENSE-MIT, LICENSE-APACHE,++NOTICE and ATTRIBUTION.md) travel with the bundle and are listed in++STATUS.json. The full attribution is in ATTRIBUTION.md.++++The LightCraft web module embeds two fonts under the SIL Open Font License++1.1: BIZ UDPGothic (the interface face) and Noto Sans Thai (for Thai file++names). Their licence texts travel with the bundle as OFL-biz-ud-pgothic.txt++and OFL-noto-sans-thai.txt.++++The OpenVibe.Studio service itself (the code in this repository) is MIT++licensed; see LICENSE.@@@ -1,3 +1,70 @@# OpenVibe.Studio--The creative apps on the OpenVibe network: Photo (PhotoCraft) and Light (LightCraft). The service lands in the next pull request.++Photo and Light: two open-source creative apps served inside an OpenVibe account.++++- **Photo** is [PhotoCraft](https://github.com/storytold/photocraft), a layered photo editor.++- **Light** is [LightCraft](https://github.com/storytold/lightcraft), a photo library and RAW developer.++++The apps are the work of their own authors and are used under their own licences (MIT OR++Apache-2.0). We build them from pinned release tags, serve them under an OpenVibe sign-in, and++keep the licence and attribution with every build. See [ATTRIBUTION.md](ATTRIBUTION.md) and++[STATUS.json](STATUS.json).++++## What is in here++++ server/ the service: pages, the API, the adapters, the OpenVibe clients++ public/js/ the browser side: one driver per app, and the layer around it++ test/ the tests++ scripts/ the bundle build and the first-frame smoke test++ .github/workflows/ ci.yml (tests and the pinned security workflow) and bundle.yml (the builds)++++## Running it++++ npm ci++ npm start # http://127.0.0.1:5155, the drill port is 15155++++ npm test # the tests++ node scripts/smoke.js --app light --dir artifacts/light # the first-frame check++++The service serves the app bundles from `STUDIO_BUNDLE_ROOT` (by default `public/apps`). Without a++bundle the app page says so instead of showing an empty frame.++++## The bundles++++A bundle is never kept in git. The `bundles` workflow checks out the pinned upstream tags, builds++them (Rust stable, `wasm32-unknown-unknown`, Trunk 0.21.14 for PhotoCraft, `cargo xtask web` and++wasm-bindgen 0.2.129 for LightCraft), runs the smoke test and publishes the result with a++checksum file. The deploy fetches that artifact, checks the checksums and unpacks it into the++bundle root.++++The smoke test is the gate: the build fails unless the app reaches its first frame in headless++Chrome. A bundle that loads but never starts is worse than no bundle.++++PhotoCraft is built by us on purpose. The published web release archive does not start: its loader++looks for the module under a path the archive does not have.++++## The layer around an app++++Every app sits in an iframe inside our own page, with the OpenVibe navbar outside it. One adapter++per app keeps the same contract, so a base can be swapped:++++ open(file) open a file from the person's OpenVibe library++ save() hand an export to the person's OpenVibe.Download drive++ commands() the app's own commands++++- **LightCraft** speaks the documented host bridge (`lightcraft:open`, `lightcraft:cmd`,++ `lightcraft:save`) to a same-origin parent, so files open and exports come back.++- **PhotoCraft** has no scriptable API in the browser: a file is dropped on the app, and there is++ no export the page can ask for. The app page says so.++++## Honest limits++++- Heavy first load: both apps are native editors compiled to WebAssembly.++- A phone with 4 GB of RAM can run out of memory on big files: a 4 GB Photoshop file used 2.7-3.0 GB.++- Online only: neither app caches for offline use.++- Cut (FilmCraft) is not part of Studio v0.++++## Licence++++The service is MIT licensed ([LICENSE](LICENSE)). The apps keep their own licences, and the++attribution is in [ATTRIBUTION.md](ATTRIBUTION.md).nnew file mode 100644@@@ -0,0 +1,95 @@++{++ "repository": "OpenVibers/OpenVibe.Studio",++ "stage": "beta",++ "code": true,++ "product": "Photo and Light, the creative apps on the OpenVibe network",++ "runtime": "node 22, Express, the OpenVibe Shared shell and the openvibe-sdk SSO client; the apps themselves are Rust compiled to WebAssembly (Trunk for PhotoCraft, cargo xtask web for LightCraft) and served as static bundles",++ "port": 5155,++ "drill_port": 15155,++ "domain": "openvibe.studio",++ "contracts": "openvibe-contracts v0.346.0, openvibe-sdk v0.101.0, openvibe-shared v4.156.0",++ "capabilities": [++ "studio.apps.list",++ "studio.files.list",++ "studio.file.open",++ "studio.file.save"++ ],++ "deployed": false,++ "deployment": "Not deployed in this pull request. openvibe.studio stays parked until the owner launches it; the service listens on 127.0.0.1:5155 and the drill port is 15155. The bundles are built by the bundles workflow on every push to main and published as the release bundles-<photo tag>-<light tag> with a SHA256SUMS file; scripts/fetch-bundles.sh fetches that release on the host with curl (no GitHub account), checks the sums and swaps the tree in one step. No bundle is kept in git.",++ "bases": [++ {++ "id": "photo",++ "name": "PhotoCraft",++ "repository": "https://github.com/storytold/photocraft",++ "tag": "v0.21.0",++ "licence": "MIT OR Apache-2.0",++ "licence_files": [++ "LICENSE-MIT",++ "LICENSE-APACHE",++ "NOTICE",++ "ATTRIBUTION.md"++ ],++ "built_by": "our CI (Rust stable, wasm32-unknown-unknown, Trunk 0.21.14)",++ "why": "the published web release archive does not start, so the build is made from the pinned tag and checked by a first-frame smoke test",++ "adapter": "server/adapters/photo.js",++ "capabilities": {++ "open": true,++ "save": false,++ "commands": false++ }++ },++ {++ "id": "light",++ "name": "LightCraft",++ "repository": "https://github.com/storytold/lightcraft",++ "tag": "v0.19.0",++ "licence": "MIT OR Apache-2.0",++ "licence_files": [++ "LICENSE-MIT",++ "LICENSE-APACHE",++ "NOTICE",++ "ATTRIBUTION.md",++ "OFL-biz-ud-pgothic.txt",++ "OFL-noto-sans-thai.txt"++ ],++ "built_by": "our CI (Rust stable, wasm32-unknown-unknown, cargo xtask web, wasm-bindgen 0.2.129)",++ "why": "its web build works and speaks the documented host bridge, so files can be opened and exports handed over",++ "adapter": "server/adapters/light.js",++ "capabilities": {++ "open": true,++ "save": true,++ "commands": true++ }++ }++ ],++ "excluded": [++ {++ "name": "Cut (FilmCraft)",++ "reason": "desktop and agent only; not part of Studio v0"++ }++ ],++ "features": [++ "one service for both apps, modelled on OpenVibe.Computer: the Shared navbar, the sign-in, STATUS.json and a pinned security workflow",++ "the apps are served at /photo and /light inside our own layer: the navbar sits outside the app frame",++ "one adapter per app with the same contract (open(file), save(), commands()), so a base can be swapped",++ "Open from OpenVibe: the files a person owns in OpenVibe.Download and the pictures in OpenVibe.Pics",++ "Save to OpenVibe: an export goes to the person's OpenVibe.Download drive (start, parts, complete)",++ "the headers the WebAssembly builds need: application/wasm, no-cache on the loader, immutable on the hashed parts, no COOP/COEP (the builds are not cross-origin isolated and do not need it)",++ "a build smoke test that fails the build unless the app reaches its first frame in headless Chrome",++ "the home states the measured limits, credits each upstream project and links its licence"++ ],++ "limits_measured": [++ "LightCraft reaches its first frame in about 2.5 s at 1280 px and 0.6 s at 390 px; PhotoCraft in about 1.3 s once its loader path is right",++ "a 4 GB Photoshop file passes PhotoCraft's cap but uses 2.7-3.0 GB of memory: a phone with 4 GB can run out of memory on big files",++ "neither app works offline: there is no service worker",++ "OpenVibe.Download has no route that hands a file's bytes to a person's token yet, so a file opens here only through a public share link"++ ],++ "tests": [++ "the build smoke test (scripts/smoke.js, run by the bundles workflow)",++ "the headers of the bundles (server/bundles.js)",++ "the adapter contract with a fake app (test/adapters.test.js)",++ "the sign-in gating of Save, the files listing and the page rendering (test/studio.test.js)",++ "the OpenVibe.Download client (test/media.test.js)"++ ],++ "updated": "2026-11-08"++}nnew file mode 100644@@@ -0,0 +1,60 @@++# Launch sequence for openvibe.studio++++This pull request prepares the service, the builds and the host files. Opus performs the host-level++swap after review; openvibe.studio stays parked until then, and no host change is part of this++pull request.++++1. **DNS and Cloudflare:** make sure the `openvibe.studio` 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.++2. **TLS:** issue `/etc/letsencrypt/live/openvibe.studio/{fullchain.pem,privkey.pem}` for apex and++ `www` before enabling the TLS server block.++3. **Network OAuth:** deploy the Network change with the Studio cutover. It seeds the confidential++ OAuth client `studio`, the redirect URI `https://openvibe.studio/auth/callback`, and lists++ Studio in the Network Frame.++4. **Service:** run `sudo ovhost service add studio`. It clones this repository into++ `/opt/openvibe.studio`, creates `/etc/openvibe/studio.env` with mode 0600 and prints the++ remaining steps without printing secrets. Use the Network service-principal setup to create or++ rotate the client `studio`; set `OV_OAUTH_CLIENT_ID=studio`. The unit is++ `deploy/systemd/openvibe-studio.service`: it runs as `ubuntu` from `/opt/openvibe.studio` on++ `127.0.0.1:5155` with the same hardening as the other room services. If the host tooling does++ not install it, put it in place by hand:++++ sudo cp deploy/systemd/openvibe-studio.service /etc/systemd/system/++ sudo systemctl daemon-reload++ sudo systemctl enable --now openvibe-studio++++ Then check `http://127.0.0.1:5155/api/health`.++5. **The bundles:** the `bundles` workflow builds PhotoCraft and LightCraft from their pinned++ tags on every push to `main`, checks that each build reaches its first frame, and publishes the++ two trees as one tarball plus a `SHA256SUMS` file in the release++ `bundles-<photo tag>-<light tag>`. The release is public: the host fetches it with curl and++ needs no GitHub account and no token.++++ sudo -u ubuntu STUDIO_BUNDLE_ROOT=/var/lib/openvibe-studio/apps \++ /opt/openvibe.studio/scripts/fetch-bundles.sh++++ Run it **after each deploy** and **whenever the pinned tags change** (the release name follows++ the tags, so a new tag means a new release). The script checks the tarball against++ `SHA256SUMS` before it unpacks anything, and it swaps the bundle tree in one step: the root++ becomes a link to `public/versions/<release>`, so a running service never sees half a bundle.++ Restart the service afterwards so it picks the new tree up.++6. **Vhost and parked redirect:** the reference vhost is `deploy/nginx/openvibe.studio.conf`. It++ sends plain HTTP to HTTPS, `www` to the apex, rate-limits `/auth/` and `/api/` (zones++ `studio_auth` and `studio_api`), caps request bodies at 64k (the apps run in the browser) and++ serves the bundle files under `/apps/` from disk with the rules the WebAssembly build needs:++ `application/wasm`, no-cache on the loader and the part list, and a year of immutable cache on++ the hashed parts only. Its bundle root is `/opt/openvibe.studio/public`, so keep it in step with++ `STUDIO_BUNDLE_ROOT` if that path differs. After review and the readiness checks, Opus removes++ the `openvibe.studio` redirect blocks from Network's `parked-domains.conf`, installs the vhost++ with `sudo ovhost nginx render studio --install`, runs `nginx -t` and reloads nginx as one++ host-level swap.++7. **Verify:** check `/`, `/photo`, `/light`, `/api/health` and `/auth/login` through HTTPS, and++ that `/apps/light/lightcraft_web_bg.wasm` answers with `application/wasm`.++++Two things to keep in mind at launch. The 64k body limit in the vhost fits the pages and the app++frames; a save that carries a large export through the service does not fit in it, so big exports++have to be saved from the app itself. And the bundles are a moving target: a new pinned tag means a++new release, and the fetch script has to run again before the service serves the new build.++++The drill port is the service port plus 10000: `5155` serves the site, `15155` is the drill.nnew file mode 100644@@@ -0,0 +1,95 @@++## openvibe.studio — reference vhost for ov-vhost-install.++## Studio answers pages and /api/ on 127.0.0.1:5155. The app bundles under /apps/ are static++## files on disk: the WebAssembly needs its own type, and only the hashed files may be cached hard.++limit_req_zone $binary_remote_addr zone=studio_auth:10m rate=10r/m;++limit_req_zone $binary_remote_addr zone=studio_api:10m rate=5r/s;++++# A bundle file whose name carries a content hash never changes, so it may be cached for a year.++# Every other file keeps its name from release to release and has to be revalidated.++map $uri $studio_cache {++ default "no-cache";++ "~^/apps/.+\.part-[0-9a-f]+$" "public, max-age=31536000, immutable";++ "~^/apps/.+-[0-9a-f]{8,}\.[a-z0-9]+$" "public, max-age=31536000, immutable";++}++++server {++ listen 80;++ listen [::]:80;++ server_name openvibe.studio www.openvibe.studio;++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://openvibe.studio$request_uri; }++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name www.openvibe.studio;++ ssl_certificate /etc/letsencrypt/live/openvibe.studio/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.studio/privkey.pem;++ return 301 https://openvibe.studio$request_uri;++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name openvibe.studio;++ ssl_certificate /etc/letsencrypt/live/openvibe.studio/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.studio/privkey.pem;++ access_log /var/log/nginx/openvibe.studio.access.log;++ error_log /var/log/nginx/openvibe.studio.error.log;++ # The apps run in the browser and a save goes to the OpenVibe APIs, so the body stays small.++ client_max_body_size 64k;++ 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;++++ # The app bundles: static files under the bundle root. Production keeps them outside the checkout++ # (STUDIO_BUNDLE_ROOT=/var/lib/openvibe-studio/apps, filled by scripts/fetch-bundles.sh) so a deploy's++ # git pull never touches them. The types are listed here because the module is only compiled by the++ # browser when it is served as application/wasm.++ location /apps/ {++ root /var/lib/openvibe-studio;++ types {++ text/html html;++ text/css css;++ text/javascript js mjs;++ application/wasm wasm;++ application/json json;++ text/plain txt;++ text/xml xml;++ font/otf otf;++ font/woff woff;++ font/woff2 woff2;++ image/png png;++ image/jpeg jpg jpeg;++ image/svg+xml svg;++ image/webp webp;++ image/x-icon ico;++ }++ default_type application/octet-stream;++ 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;++ add_header Cache-Control $studio_cache always;++ try_files $uri =404;++ }++ location = /auth/me { limit_req zone=studio_api burst=30 nodelay; limit_req_status 429; proxy_pass http://127.0.0.1:5155; }++ location /auth/ { limit_req zone=studio_auth burst=10 nodelay; limit_req_status 429; proxy_pass http://127.0.0.1:5155; }++ location /api/ { limit_req zone=studio_api burst=20 nodelay; limit_req_status 429; proxy_pass http://127.0.0.1:5155; }++ location ~* ^/(metrics|internal)(/|$) { return 404; }++ location ~ /\.(?!well-known) { deny all; }++ location / { proxy_pass http://127.0.0.1:5155; }++}nnew file mode 100644@@@ -0,0 +1,27 @@++[Unit]++Description=OpenVibe.Studio — Photo and Light, the creative apps on the OpenVibe network.++After=network.target++Wants=openvibe-network.service++++[Service]++Type=simple++User=ubuntu++Group=ubuntu++WorkingDirectory=/opt/openvibe.studio++EnvironmentFile=/etc/openvibe/studio.env++Environment=NODE_ENV=production++Environment=PORT=5155++Environment=HOST=127.0.0.1++Environment=PATH=/usr/local/bin:/usr/bin:/bin++ExecStart=/usr/bin/env node /opt/openvibe.studio/server/index.js++Restart=always++RestartSec=5++KillSignal=SIGTERM++TimeoutStopSec=15++NoNewPrivileges=true++PrivateTmp=true++ProtectSystem=strict++ProtectHome=true++++[Install]++WantedBy=multi-user.targetnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/home-1280-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/home-1280-light.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/home-390-dark.png differnnew file mode 100644BBinary files /dev/null and b/docs/screenshots/home-390-light.png differnnew file mode 100644@@@ -0,0 +1,1432 @@++{++ "name": "openvibe-studio",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-studio",++ "version": "0.1.0",++ "license": "MIT",++ "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.101.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.156.0"++ },++ "devDependencies": {++ "playwright-core": "1.56.1"++ },++ "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.101.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.101.0",++ "integrity": "sha512-W0WVhpc4cL0zJOW7IRAzz1polzR1MU571SMyDUEh9CeJWlocENlISbz9rr78AZmrcmqCvAn+25D3aLTNQpO7Ug==",++ "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.156.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.156.0",++ "integrity": "sha512-2GCk8sILS2FH3mKu7boRwc73TxMJjsaC1kgOf4j3qpHJnENlRC52HWrFg7neF/0m4XsZL3x2EvMC0FSM4bTsKw==",++ "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/playwright-core": {++ "version": "1.56.1",++ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",++ "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",++ "dev": true,++ "license": "Apache-2.0",++ "bin": {++ "playwright-core": "cli.js"++ },++ "engines": {++ "node": ">=18"++ }++ },++ "node_modules/proxy-addr": {++ "version": "2.0.8",++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",++ "license": "MIT",++ "dependencies": {++ "forwarded": "0.2.0",++ "ipaddr.js": "1.9.1"++ },++ "engines": {++ "node": ">= 0.10"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/qs": {++ "version": "6.16.2",++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",++ "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==",++ "license": "BSD-3-Clause",++ "dependencies": {++ "es-define-property": "^1.0.1",++ "side-channel": "^1.1.1"++ },++ "engines": {++ "node": ">=0.6"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/range-parser": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/raw-body": {++ "version": "2.5.3",++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/require-from-string": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/safe-buffer": {++ "version": "5.2.1",++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/safer-buffer": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",++ "license": "MIT"++ },++ "node_modules/semver": {++ "version": "7.8.6",++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz",++ "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==",++ "license": "ISC",++ "bin": {++ "semver": "bin/semver.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/send": {++ "version": "0.19.2",++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "1.2.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.1",++ "mime": "1.6.0",++ "ms": "2.1.3",++ "on-finished": "~2.4.1",++ "range-parser": "~1.2.1",++ "statuses": "~2.0.2"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/send/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/serve-static": {++ "version": "1.16.3",++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",++ "license": "MIT",++ "dependencies": {++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "parseurl": "~1.3.3",++ "send": "~0.19.1"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/setprototypeof": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",++ "license": "ISC"++ },++ "node_modules/side-channel": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4",++ "side-channel-list": "^1.0.1",++ "side-channel-map": "^1.0.1",++ "side-channel-weakmap": "^1.0.2"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-list": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-map": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-weakmap": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3",++ "side-channel-map": "^1.0.1"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/statuses": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/toidentifier": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",++ "license": "MIT",++ "engines": {++ "node": ">=0.6"++ }++ },++ "node_modules/type-is": {++ "version": "1.6.18",++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",++ "license": "MIT",++ "dependencies": {++ "media-typer": "0.3.0",++ "mime-types": "~2.1.24"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/uhyphen": {++ "version": "0.2.0",++ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",++ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",++ "license": "ISC"++ },++ "node_modules/unpipe": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/utils-merge": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4.0"++ }++ },++ "node_modules/vary": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,29 @@++{++ "name": "openvibe-studio",++ "version": "0.1.0",++ "private": true,++ "description": "OpenVibe.Studio: Photo and Light — the creative apps on the OpenVibe network",++ "main": "server/index.js",++ "scripts": {++ "start": "node server/index.js",++ "dev": "NODE_ENV=development node server/index.js",++ "test": "node --test test/*.test.js",++ "smoke": "node scripts/smoke.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.101.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.156.0"++ },++ "devDependencies": {++ "playwright-core": "1.56.1"++ },++ "license": "MIT"++}nnew file mode 100644@@@ -0,0 +1,70 @@++: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; }++.studio-page { box-sizing: border-box; max-width: 1180px; margin: 0 auto; padding: 18px 24px 80px; }++.studio-page a { color: var(--accent); text-underline-offset: 3px; }++.hero { padding: 56px 0 40px; max-width: 780px; }++.hero h1, .page-intro h1, .app-head h1 { font-size: clamp(2.1rem, 4.6vw, 3.4rem); line-height: 1.05; letter-spacing: -.045em; margin: 12px 0 18px; }++.hero p, .page-intro p, .app-head p { color: var(--muted); font-size: 1.06rem; line-height: 1.6; max-width: 68ch; }++.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; font-size: .95rem;++}++.button-primary { background: var(--accent); color: white !important; }++.button-secondary { background: var(--panel); border-color: var(--line); color: inherit; }++.button-quiet { background: var(--panel); border-color: var(--line); min-height: 38px; padding: 0 14px; font-size: .9rem; color: inherit; }++.button-primary:disabled, .button-secondary:disabled, .button-quiet:disabled { opacity: .55; cursor: not-allowed; }++.section-heading { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; justify-content: space-between; margin: 34px 0 18px; }++.section-heading h2 { font-size: 1.6rem; letter-spacing: -.02em; margin: 8px 0 0; }++.section-heading p { color: var(--muted); margin: 0; max-width: 46ch; font-size: .95rem; }++.app-grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 330px), 1fr)); }++.app-card { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 20px 22px 22px; }++.app-card h3 { font-size: 1.22rem; line-height: 1.25; margin: 10px 0 10px; letter-spacing: -.015em; }++.app-card p { color: var(--muted); line-height: 1.55; font-size: .97rem; }++.app-limits { margin: 12px 0 16px; padding-left: 18px; color: var(--muted); font-size: .9rem; line-height: 1.5; }++.app-limits li { margin-bottom: 6px; }++.app-licence { font-size: .85rem; border-top: 1px dashed var(--line); padding-top: 12px; }++.limit-list { margin: 0; padding-left: 20px; color: var(--muted); line-height: 1.6; max-width: 78ch; }++.limit-list li { margin-bottom: 8px; }++.fine-print { color: var(--muted); font-size: .86rem; line-height: 1.5; max-width: 74ch; }++.app-head { display: grid; gap: 12px; margin: 30px 0 18px; }++.app-head h1 { margin: 8px 0 6px; font-size: clamp(1.6rem, 3.4vw, 2.2rem); }++.app-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }++.app-commands { display: flex; flex-wrap: wrap; gap: 8px; }++.app-status { min-height: 22px; margin: 4px 0 0; font-size: .95rem; color: var(--muted); }++.app-output { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; overflow: auto; max-height: 260px; font-size: .82rem; }++.app-files { margin: 18px 0; padding: 16px 18px; border: 1px solid var(--line); border-radius: 14px; background: var(--panel); }++.app-files h2 { font-size: 1.05rem; margin: 0 0 10px; }++.app-files ul { list-style: none; margin: 0 0 8px; padding: 0; display: grid; gap: 8px; }++.app-files li { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; justify-content: space-between; }++.file-name { font-size: .95rem; overflow-wrap: anywhere; }++.app-frame-wrap { margin-top: 18px; border: 1px solid var(--line); border-radius: 14px; overflow: hidden; background: #1c1c1c; }++.app-frame { display: block; width: 100%; height: min(78vh, 900px); border: 0; }++.app-missing { color: var(--muted); border: 1px dashed var(--line); border-radius: 14px; padding: 22px; }++.page-intro { padding: 40px 0 10px; }++@media (max-width: 520px) {++ .studio-page { padding: 12px 16px 60px; }++ .hero { padding: 34px 0 26px; }++ .app-frame { height: 72vh; }++}++++/* Theme tokens (Shared presets: a person's theme and light/dark follow), over the 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 short home (11-08): three features per app as quiet chips, one width buttons on a phone. */++.app-features { list-style: none; display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 16px; padding: 0; }++.app-features li { padding: 4px 10px; border: 1px solid var(--line); border-radius: 999px; font-size: .85rem; color: var(--muted); }++.app-card h3 { font-size: 1.5rem; margin: 0 0 6px; }++#limits h2 { font-size: 1.25rem; margin: 0 0 10px; }++#licences .fine-print a { color: inherit; }++@media (max-width: 600px) { .hero .button-primary, .hero .button-secondary { display: flex; align-items: center; justify-content: center; min-height: 48px; padding-top: 0; padding-bottom: 0; margin: 0 0 10px; } }nnew file mode 100644@@@ -0,0 +1,76 @@++'use strict';++/*++ * The browser side of the adapters: one driver per app, all with the same contract++ * (open(file), save(), run(command, params)). A driver only talks to the app inside the frame;++ * everything around it - the navbar, the sign-in, the OpenVibe files - is the same for every app,++ * so a base can be swapped without touching the page.++ */++(function (root, factory) {++ const drivers = factory();++ if (typeof module === 'object' && module.exports) module.exports = drivers;++ else root.StudioDrivers = drivers;++})(typeof globalThis !== 'undefined' ? globalThis : this, function () {++ function photocraft() {++ return {++ id: 'photocraft',++ async open(ctx, file) {++ const win = ctx.window;++ if (!win || !win.document) {++ return { ok: false, code: 'photo.frame.missing', detail: 'The PhotoCraft frame is not ready yet.' };++ }++ const bytes = file.bytes instanceof Uint8Array ? file.bytes : new Uint8Array(file.bytes || []);++ const transfer = new win.DataTransfer();++ transfer.items.add(new win.File([bytes], file.name || 'photo.jpg', { type: file.type || 'application/octet-stream' }));++ const fire = (type) => win.document.dispatchEvent(new win.DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: transfer }));++ fire('dragenter');++ fire('dragover');++ fire('drop');++ return { ok: true, detail: 'Dropped ' + (file.name || 'the file') + ' into PhotoCraft.' };++ },++ async save() {++ return {++ ok: false, code: 'photo.save.unsupported',++ detail: "PhotoCraft's browser build has no scriptable export: export from the app and the file downloads to your browser.",++ };++ },++ async run() {++ return { ok: false, code: 'photo.commands.unsupported', detail: "PhotoCraft's browser build has no scriptable commands." };++ },++ };++ }++++ function lightcraft() {++ return {++ id: 'lightcraft',++ async open(ctx, file) {++ const bytes = file.bytes instanceof Uint8Array ? file.bytes : new Uint8Array(file.bytes || []);++ ctx.window.postMessage({ type: 'lightcraft:open', name: file.name || 'photo.jpg', bytes: bytes }, ctx.origin);++ return { ok: true, detail: 'Sent ' + (file.name || 'the file') + ' to LightCraft.' };++ },++ async save(ctx) {++ ctx.window.postMessage({ type: 'lightcraft:cmd', id: 'app.export', params: '{}' }, ctx.origin);++ const handed = await ctx.nextSave(20000);++ if (!handed) {++ return { ok: false, code: 'light.save.timeout', detail: 'LightCraft did not hand over a file within 20 seconds.' };++ }++ if (handed.ok === false) {++ return { ok: false, code: 'light.export.failed', detail: String(handed.detail || 'LightCraft could not export that file.') };++ }++ return { ok: true, detail: 'LightCraft handed over ' + handed.file.name + '.', file: handed.file };++ },++ async run(ctx, commandId, params) {++ ctx.window.postMessage({ type: 'lightcraft:cmd', id: commandId, params: JSON.stringify(params || {}) }, ctx.origin);++ const value = await ctx.nextResponse(20000);++ if (!value) {++ return { ok: false, code: 'light.command.timeout', detail: 'LightCraft did not answer ' + commandId + ' within 20 seconds.' };++ }++ if (value.ok === false) {++ return { ok: false, code: 'light.command.failed', detail: String(value.value || 'the command failed') };++ }++ return { ok: true, detail: commandId + ' answered.', value: value.value };++ },++ };++ }++++ return { photocraft: photocraft, lightcraft: lightcraft };++});nnew file mode 100644@@@ -0,0 +1,232 @@++'use strict';++/*++ * The layer around an app: the OpenVibe files a person owns, the way into the app, the way back++ * out, and the app's own commands. It talks to the app only through the driver, so the app can++ * be swapped without changing this file.++ */++(function () {++ const root = document.querySelector('[data-studio-app]');++ if (!root) return;++ const appId = root.getAttribute('data-studio-app');++ const driverName = root.getAttribute('data-studio-driver');++ const signedIn = root.getAttribute('data-studio-signed-in') === 'true';++ const drivers = window.StudioDrivers || {};++ const factory = drivers[driverName];++ if (!factory) return;++ const driver = factory();++ const statusEl = root.querySelector('[data-role="status"]');++ const outputEl = root.querySelector('[data-role="output"]');++ const filesEl = root.querySelector('[data-role="files"]');++ const fileListEl = root.querySelector('[data-role="file-list"]');++ const filesNoteEl = root.querySelector('[data-role="files-note"]');++ const iframe = root.querySelector('iframe');++++ let pendingSave = null;++ let pendingResponse = null;++++ function say(text) {++ if (statusEl) statusEl.textContent = text || '';++ }++++ function show(value) {++ if (!outputEl) return;++ if (!value) {++ outputEl.hidden = true;++ outputEl.textContent = '';++ return;++ }++ outputEl.hidden = false;++ outputEl.textContent = typeof value === 'string' ? value : JSON.stringify(value, null, 2);++ }++++ function nextSave(timeout) {++ return new Promise((resolve) => {++ pendingSave = resolve;++ window.setTimeout(() => {++ if (pendingSave === resolve) {++ pendingSave = null;++ resolve(null);++ }++ }, timeout || 20000);++ });++ }++++ function nextResponse(timeout) {++ return new Promise((resolve) => {++ pendingResponse = resolve;++ window.setTimeout(() => {++ if (pendingResponse === resolve) {++ pendingResponse = null;++ resolve(null);++ }++ }, timeout || 20000);++ });++ }++++ const ctx = {++ origin: window.location.origin,++ iframe: iframe,++ window: iframe ? iframe.contentWindow : null,++ nextSave: nextSave,++ nextResponse: nextResponse,++ };++++ window.addEventListener('message', (event) => {++ if (!iframe || event.source !== iframe.contentWindow) return;++ const data = event.data || {};++ if (data.type === 'lightcraft:save') {++ const file = { name: data.name || 'export', bytes: data.bytes instanceof ArrayBuffer ? new Uint8Array(data.bytes) : new Uint8Array(data.bytes || []) };++ if (pendingSave) {++ const resolve = pendingSave;++ pendingSave = null;++ resolve({ ok: true, file: file });++ }++ return;++ }++ if (data.type === 'lightcraft:response') {++ const answer = { ok: data.ok !== false, value: data.value };++ if (pendingSave) {++ const resolve = pendingSave;++ pendingSave = null;++ resolve({ ok: answer.ok, detail: answer.value });++ return;++ }++ if (pendingResponse) {++ const resolve = pendingResponse;++ pendingResponse = null;++ resolve(answer);++ }++ return;++ }++ if (data.type === 'lightcraft:failed') {++ say('The app could not start: ' + (data.error || 'no reason given'));++ }++ });++++ function addFileItem(list, label, note, onOpen) {++ const item = document.createElement('li');++ const button = document.createElement('button');++ button.type = 'button';++ button.className = 'button-quiet';++ button.textContent = 'Open';++ button.addEventListener('click', onOpen);++ const name = document.createElement('span');++ name.className = 'file-name';++ name.textContent = label;++ item.appendChild(name);++ if (note) {++ const meta = document.createElement('small');++ meta.textContent = note;++ item.appendChild(meta);++ }++ item.appendChild(button);++ list.appendChild(item);++ }++++ async function openBytes(source, id, name) {++ say('Opening ' + name + '…');++ const res = await fetch('/api/v1/studio/files/' + encodeURIComponent(source) + '/' + encodeURIComponent(id) + '/bytes', { credentials: 'same-origin' });++ if (!res.ok) {++ const problem = await res.json().catch(() => ({}));++ say(problem.detail || problem.title || 'That file could not be opened.');++ return;++ }++ const bytes = new Uint8Array(await res.arrayBuffer());++ const result = await driver.open(ctx, { name: name, type: res.headers.get('content-type') || 'application/octet-stream', bytes: bytes });++ say(result.detail || (result.ok ? 'Opened.' : 'The app did not take the file.'));++ }++++ async function loadFiles() {++ if (filesEl) filesEl.hidden = false;++ if (fileListEl) fileListEl.textContent = '';++ say('Looking up your OpenVibe files…');++ const res = await fetch('/api/v1/studio/files', { credentials: 'same-origin' });++ if (res.status === 401) {++ say('Sign in to open files from OpenVibe.');++ if (filesNoteEl) {++ filesNoteEl.textContent = '';++ const link = document.createElement('a');++ link.href = '/auth/login?next=' + encodeURIComponent(window.location.pathname);++ link.textContent = 'Sign in with OpenVibe';++ filesNoteEl.appendChild(link);++ }++ return;++ }++ if (!res.ok) {++ say('Your OpenVibe files could not be read just now.');++ return;++ }++ const data = await res.json();++ const problems = Array.isArray(data.problems) ? data.problems : [];++ if (fileListEl) {++ for (const file of (data.download && data.download.files) || []) {++ addFileItem(fileListEl, file.name || file.id, file.content_type || '', () => openBytes('download', file.id, file.name || file.id));++ }++ for (const image of (data.pics && data.pics.images) || []) {++ addFileItem(fileListEl, image.name || image.slug || image.id, 'OpenVibe.Pics', () => openBytes('pics', image.slug || image.id, image.name || image.slug || image.id));++ }++ if (!fileListEl.childNodes.length) {++ const empty = document.createElement('li');++ empty.textContent = 'No files found in your OpenVibe drive.';++ fileListEl.appendChild(empty);++ }++ }++ if (filesNoteEl) {++ filesNoteEl.textContent = problems.length++ ? problems.map((one) => one.source + ': ' + (one.detail || one.code)).join(' · ')++ : 'Files come from OpenVibe.Download and OpenVibe.Pics.';++ }++ say('Your OpenVibe files are listed below.');++ }++++ async function saveToOpenVibe() {++ if (!signedIn) {++ say('Sign in to save to OpenVibe.');++ return;++ }++ say('Asking the app for the file…');++ const result = await driver.save(ctx);++ if (!result.ok || !result.file) {++ say(result.detail || 'There was nothing to save.');++ return;++ }++ const res = await fetch('/api/v1/studio/save', {++ method: 'POST',++ credentials: 'same-origin',++ headers: { 'content-type': result.file.contentType || 'application/octet-stream', 'x-file-name': result.file.name || 'export' },++ body: result.file.bytes,++ });++ if (!res.ok) {++ const problem = await res.json().catch(() => ({}));++ say(problem.detail || problem.title || 'The file could not be saved to OpenVibe.');++ return;++ }++ say('Saved ' + (result.file.name || 'the file') + ' to your OpenVibe drive.');++ }++++ root.addEventListener('click', async (event) => {++ const button = event.target.closest('button');++ if (!button) return;++ const action = button.getAttribute('data-action');++ if (action === 'open') {++ event.preventDefault();++ await loadFiles();++ return;++ }++ if (action === 'save') {++ event.preventDefault();++ await saveToOpenVibe();++ return;++ }++ const command = button.getAttribute('data-command');++ if (command) {++ event.preventDefault();++ say('Running ' + command + '…');++ const result = await driver.run(ctx, command, {});++ say(result.detail || 'Done.');++ show(result.ok ? result.value : null);++ }++ });++++ say(appId + ' is ready.');++})();nnew file mode 100644@@@ -0,0 +1,89 @@++{++ "openapi": "3.1.0",++ "info": {++ "title": "OpenVibe.Studio",++ "version": "0.1.0",++ "description": "Photo and Light: two open-source creative apps built from pinned release tags and served inside an OpenVibe account."++ },++ "servers": [++ {++ "url": "https://openvibe.studio"++ }++ ],++ "paths": {++ "/api/v1/studio/apps": {++ "get": {++ "summary": "The apps Studio serves, their capabilities and their commands.",++ "responses": {++ "200": {++ "description": "The catalogue."++ }++ }++ }++ },++ "/api/v1/studio/files": {++ "get": {++ "summary": "The files and pictures the signed-in person owns.",++ "responses": {++ "200": {++ "description": "Files and pictures, with any source that failed."++ },++ "401": {++ "description": "Sign in required."++ }++ }++ }++ },++ "/api/v1/studio/files/{source}/{id}/bytes": {++ "get": {++ "summary": "The bytes of one file or picture.",++ "parameters": [++ {++ "name": "source",++ "in": "path",++ "required": true,++ "schema": {++ "type": "string",++ "enum": [++ "download",++ "pics"++ ]++ }++ },++ {++ "name": "id",++ "in": "path",++ "required": true,++ "schema": {++ "type": "string"++ }++ }++ ],++ "responses": {++ "200": {++ "description": "The file."++ },++ "401": {++ "description": "Sign in required."++ },++ "501": {++ "description": "The host has no byte route for a person's token."++ }++ }++ }++ },++ "/api/v1/studio/save": {++ "post": {++ "summary": "Save a file to the person's OpenVibe.Download drive.",++ "responses": {++ "201": {++ "description": "Saved."++ },++ "401": {++ "description": "Sign in required."++ }++ }++ }++ }++ }++}nnew file mode 100755@@@ -0,0 +1,71 @@++#!/usr/bin/env bash++# Builds the app bundles from their pinned upstream tags into artifacts/.++#++# The bundles are never kept in git: the deploy fetches them from the build artifacts by tag and++# checksum. Each build keeps the upstream licence and attribution files with the bundle.++#++# Usage: scripts/build-bundles.sh [photocraft|lightcraft|all]++set -euo pipefail++ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"++VENDOR="$ROOT/vendor"++ARTIFACTS="$ROOT/artifacts"++PHOTOCRAFT_TAG="v0.21.0"++LIGHTCRAFT_TAG="v0.19.0"++CRAFT_FONTS_REF="b076417b5880bd4154bd22f00695b1d28c6bea83"++WHICH="${1:-all}"++++keep_docs() {++ local from="$1" to="$2" f++ for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md COPYRIGHT; do++ [ -f "$from/$f" ] && cp "$from/$f" "$to/$f" || true++ done++}++++build_photocraft() {++ echo "== PhotoCraft $PHOTOCRAFT_TAG"++ cd "$VENDOR/photocraft"++ PHOTOCRAFT_BUILD_SHA="$(git rev-parse HEAD)" ./packaging/web/package.sh++ rm -rf "$ARTIFACTS/photo"++ mkdir -p "$ARTIFACTS/photo"++ rm -rf "$VENDOR/photocraft/unpacked"++ mkdir -p "$VENDOR/photocraft/unpacked"++ unzip -q dist/release/photocraft-web-*.zip -d "$VENDOR/photocraft/unpacked"++ cp -R "$VENDOR/photocraft/unpacked"/photocraft-web-*/. "$ARTIFACTS/photo/"++ # Upstream's web package puts snippets/ at the root while app/photocraft-web.js imports ./snippets/… from app/, so the++ # app never starts (404s; upstream issues #10017/#10248). Measured fix from the Studio due diligence: a copy in app/.++ if [ -d "$ARTIFACTS/photo/snippets" ] && [ -d "$ARTIFACTS/photo/app" ] && [ ! -d "$ARTIFACTS/photo/app/snippets" ]; then++ cp -R "$ARTIFACTS/photo/snippets" "$ARTIFACTS/photo/app/snippets"++ fi++ keep_docs "$VENDOR/photocraft" "$ARTIFACTS/photo"++}++++build_lightcraft() {++ echo "== LightCraft $LIGHTCRAFT_TAG"++ cd "$VENDOR/lightcraft"++ cargo xtask web++ rm -rf "$ARTIFACTS/light"++ mkdir -p "$ARTIFACTS/light"++ cp -R target/web-site/. "$ARTIFACTS/light/"++ keep_docs "$VENDOR/lightcraft" "$ARTIFACTS/light"++ # The module embeds two OFL fonts (the UI face and the Thai face); their licences travel with it.++ for family in biz-ud-pgothic noto-sans-thai; do++ url="https://raw.githubusercontent.com/storytold/craft-fonts/$CRAFT_FONTS_REF/fonts/$family/OFL.txt"++ if curl -fsSL "$url" -o "$ARTIFACTS/light/OFL-$family.txt"; then++ echo " licence: OFL-$family.txt"++ else++ rm -f "$ARTIFACTS/light/OFL-$family.txt"++ echo " warning: could not fetch the licence of $family" >&2++ fi++ done++}++++case "$WHICH" in++ photocraft) build_photocraft ;;++ lightcraft) build_lightcraft ;;++ all) build_photocraft; build_lightcraft ;;++ *) echo "usage: $0 [photocraft|lightcraft|all]" >&2; exit 2 ;;++esac++++mkdir -p "$ARTIFACTS"++( cd "$ARTIFACTS" && find . -type f -print0 | sort -z | xargs -0 sha256sum > "$ARTIFACTS/bundles.sha256" )++echo "wrote $ARTIFACTS/bundles.sha256"nnew file mode 100755@@@ -0,0 +1,52 @@++#!/usr/bin/env bash++# Fetches the built app bundles from the GitHub release and unpacks them into the bundle root.++#++# The release is public: the host needs no GitHub account and no token, only curl.++# The tarball is checked against SHA256SUMS before anything is unpacked, and the unpack is atomic:++# the new tree is built next to the old one and the link is swapped in one step.++#++# Usage: scripts/fetch-bundles.sh [release-name]++set -euo pipefail++ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"++REPO="${OV_STUDIO_REPO:-OpenVibers/OpenVibe.Studio}"++BUNDLE_ROOT="${STUDIO_BUNDLE_ROOT:-$ROOT/public/apps}"++PHOTO_TAG="${PHOTO_TAG:-$(node -e "process.stdout.write(require('$ROOT/server/adapters/photo').base.tag)")}"++LIGHT_TAG="${LIGHT_TAG:-$(node -e "process.stdout.write(require('$ROOT/server/adapters/light').base.tag)")}"++RELEASE="${1:-bundles-$PHOTO_TAG-$LIGHT_TAG}"++TARBALL="studio-bundles-$PHOTO_TAG-$LIGHT_TAG.tar.gz"++BASE="${OV_STUDIO_RELEASE_BASE:-https://github.com/$REPO/releases/download/$RELEASE}"++++command -v curl >/dev/null || { echo "error: curl is required" >&2; exit 1; }++command -v sha256sum >/dev/null || { echo "error: sha256sum is required" >&2; exit 1; }++++WORK="$(mktemp -d "${TMPDIR:-/tmp}/studio-bundles.XXXXXX")"++cleanup() { rm -rf "$WORK"; }++trap cleanup EXIT++++echo "fetching $RELEASE from $REPO"++curl -fsSL -o "$WORK/$TARBALL" "$BASE/$TARBALL"++curl -fsSL -o "$WORK/SHA256SUMS" "$BASE/SHA256SUMS"++( cd "$WORK" && sha256sum -c SHA256SUMS )++++rm -rf "$WORK/unpacked"++mkdir -p "$WORK/unpacked"++tar -xzf "$WORK/$TARBALL" -C "$WORK/unpacked"++for app in photo light; do++ [ -f "$WORK/unpacked/$app/index.html" ] || { echo "error: the release has no $app/index.html" >&2; exit 1; }++done++++# The bundle root is a link to a versioned tree, so the swap is one step and never half done.++PARENT="$(dirname "$BUNDLE_ROOT")"++NAME="$(basename "$BUNDLE_ROOT")"++mkdir -p "$PARENT/versions"++TARGET="$PARENT/versions/$RELEASE"++rm -rf "$TARGET"++mv "$WORK/unpacked" "$TARGET"++if [ -e "$BUNDLE_ROOT" ] && [ ! -L "$BUNDLE_ROOT" ]; then++ mv "$BUNDLE_ROOT" "$PARENT/versions/previous-$(date +%s)"++fi++ln -sfn "$TARGET" "$PARENT/.$NAME.tmp"++mv -T "$PARENT/.$NAME.tmp" "$BUNDLE_ROOT"++find "$PARENT/versions" -maxdepth 1 -mindepth 1 -type d ! -name "$RELEASE" -mtime +14 -exec rm -rf {} + 2>/dev/null || true++++echo "the bundles of $RELEASE are in place at $BUNDLE_ROOT"nnew file mode 100644@@@ -0,0 +1,135 @@++'use strict';++/*++ * The build smoke test: the bundle must reach its first frame in a real browser, or the build++ * fails. A bundle that loads but never starts is worse than no bundle at all, so the check waits++ * for the app's own "it is running" signal and fails the build when it never comes.++ *++ * The bundle is served by the service itself, so the check runs against the headers a person++ * really gets: the WebAssembly type, the cache rules and the content security policy.++ *++ * Usage: node scripts/smoke.js --app photo --dir artifacts/photo [--timeout 300]++ */++const http = require('node:http');++const fs = require('node:fs');++const path = require('node:path');++const { createApp } = require('../server/app');++const { load: loadConfig } = require('../server/config');++++const args = process.argv.slice(2);++function argOf(name, fallback) {++ const index = args.indexOf(name);++ return index >= 0 && args[index + 1] ? args[index + 1] : fallback;++}++++const app = argOf('--app', 'photo');++const dir = path.resolve(argOf('--dir', path.join('artifacts', app)));++const timeout = Number(argOf('--timeout', '300')) * 1000;++++const SIGNALS = {++ photo: { loading: '#photocraft_loading', canvas: '#photocraft_canvas', failure: /PhotoCraft failed to start|PhotoCraft stopped/i },++ light: { loading: '#lightcraft_loading', canvas: '#lightcraft_canvas', failure: /LightCraft failed to load/i },++};++++async function main() {++ const signal = SIGNALS[app];++ if (!signal) {++ console.error('smoke: unknown app ' + app);++ process.exit(2);++ }++ if (!fs.existsSync(path.join(dir, 'index.html'))) {++ console.error('smoke: no index.html in ' + dir);++ process.exit(2);++ }++ let chromium;++ try {++ ({ chromium } = require('playwright-core'));++ } catch {++ console.error('smoke: playwright-core is not installed');++ process.exit(2);++ }++ const root = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'studio-smoke-'));++ fs.symlinkSync(dir, path.join(root, app), 'dir');++ const config = Object.assign(loadConfig({}), { bundleRoot: root });++ const { app: service } = createApp({ config });++ const server = http.createServer(service);++ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));++ const port = server.address().port;++ const url = 'http://127.0.0.1:' + port + '/apps/' + app + '/index.html';++ const launchArgs = ['--no-sandbox', '--disable-dev-shm-usage', '--enable-unsafe-swiftshader', '--use-angle=swiftshader'];++ const candidates = [process.env.CHROME_PATH, '/opt/google/chrome/chrome', '/usr/bin/google-chrome', '/usr/bin/chromium'].filter(Boolean);++ let browser;++ const errors = [];++ try {++ let lastError = null;++ for (const executablePath of candidates) {++ if (!fs.existsSync(executablePath)) continue;++ try {++ browser = await chromium.launch({ executablePath: executablePath, args: launchArgs });++ break;++ } catch (error) {++ lastError = error;++ }++ }++ if (!browser) {++ try {++ browser = await chromium.launch({ channel: 'chrome', args: launchArgs });++ } catch (error) {++ lastError = error;++ }++ }++ if (!browser) throw lastError || new Error('no Chrome to drive');++ const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });++ page.on('console', (message) => {++ if (message.type() === 'error') errors.push(message.text());++ });++ page.on('pageerror', (error) => errors.push(String(error)));++ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: timeout });++ const started = await page.waitForFunction(++ (args) => {++ const loading = document.querySelector(args.loading);++ const text = document.body ? document.body.innerText : '';++ if (new RegExp(args.failure).test(text)) return true;++ return !loading;++ },++ { loading: signal.loading, failure: signal.failure.source },++ { timeout: timeout },++ ).then(() => true).catch(() => false);++ const body = await page.evaluate(() => (document.body ? document.body.innerText : ''));++ const canvas = await page.evaluate((selector) => {++ const node = document.querySelector(selector);++ if (!node) return null;++ const box = node.getBoundingClientRect();++ return { width: Math.round(box.width), height: Math.round(box.height) };++ }, signal.canvas);++ if (!started) {++ console.error('smoke: ' + app + ' did not reach its first frame within ' + (timeout / 1000) + ' s');++ console.error('smoke: page text: ' + body.replace(/\s+/g, ' ').slice(0, 400));++ for (const error of errors.slice(0, 10)) console.error('smoke: console: ' + error);++ process.exitCode = 1;++ return;++ }++ if (signal.failure.test(body)) {++ console.error('smoke: ' + app + ' reported a failure: ' + body.replace(/\s+/g, ' ').slice(0, 400));++ process.exitCode = 1;++ return;++ }++ if (!canvas || !canvas.width || !canvas.height) {++ console.error('smoke: ' + app + ' has no canvas of a real size: ' + JSON.stringify(canvas));++ process.exitCode = 1;++ return;++ }++ console.log('smoke: ' + app + ' reached its first frame (canvas ' + canvas.width + 'x' + canvas.height + ')');++ if (errors.length) {++ console.log('smoke: console errors (not fatal):');++ for (const error of errors.slice(0, 10)) console.log('smoke: ' + error);++ }++ } finally {++ if (browser) await browser.close();++ await new Promise((resolve) => server.close(resolve));++ }++}++++main().catch((error) => {++ console.error('smoke: ' + (error && error.message ? error.message : String(error)));++ process.exit(1);++});nnew file mode 100644@@@ -0,0 +1,46 @@++'use strict';++++const ADAPTER_FIELDS = ['id', 'title', 'base', 'bundle', 'driver', 'capabilities', 'commands'];++++/**++ * Every adapter (one per app) must have the same shape, so a base can be swapped for another++ * without touching the layer around it.++ */++function assertAdapter(adapter) {++ if (!adapter || typeof adapter !== 'object') throw new Error('an adapter must be an object');++ for (const field of ADAPTER_FIELDS) {++ if (!(field in adapter)) throw new Error(`adapter ${adapter.id || '?'} is missing ${field}`);++ }++ if (typeof adapter.id !== 'string' || !adapter.id) throw new Error('an adapter needs a string id');++ if (typeof adapter.title !== 'string' || !adapter.title) throw new Error(`adapter ${adapter.id} needs a title`);++ const base = adapter.base;++ if (!base || typeof base.repository !== 'string' || typeof base.tag !== 'string' || typeof base.licence !== 'string') {++ throw new Error(`adapter ${adapter.id} needs base.repository, base.tag and base.licence`);++ }++ if (!Array.isArray(base.licenceFiles) || !base.licenceFiles.length) throw new Error(`adapter ${adapter.id} needs base.licenceFiles`);++ if (!adapter.bundle || typeof adapter.bundle.path !== 'string' || typeof adapter.bundle.entry !== 'string') {++ throw new Error(`adapter ${adapter.id} needs bundle.path and bundle.entry`);++ }++ if (typeof adapter.driver !== 'string' || !adapter.driver) throw new Error(`adapter ${adapter.id} needs a driver name`);++ for (const key of ['open', 'save', 'commands']) {++ if (typeof (adapter.capabilities || {})[key] !== 'boolean') throw new Error(`adapter ${adapter.id} needs capability ${key}`);++ }++ if (!Array.isArray(adapter.commands)) throw new Error(`adapter ${adapter.id} needs a commands array`);++ for (const command of adapter.commands) {++ if (!command || typeof command.id !== 'string' || !command.id || typeof command.title !== 'string' || !command.title) {++ throw new Error(`adapter ${adapter.id} has a command without an id and a title`);++ }++ }++ return adapter;++}++++/** The shape the browser side of an adapter (the driver) must have. */++function assertDriver(driver) {++ if (!driver || typeof driver !== 'object') throw new Error('a driver must be an object');++ for (const method of ['open', 'save', 'run']) {++ if (typeof driver[method] !== 'function') throw new Error(`driver ${driver.id || '?'} is missing ${method}()`);++ }++ return driver;++}++++module.exports = { assertAdapter, assertDriver, ADAPTER_FIELDS };nnew file mode 100644@@@ -0,0 +1,12 @@++'use strict';++const { assertAdapter, assertDriver } = require('./contract');++const photo = require('./photo');++const light = require('./light');++++const ADAPTERS = [photo, light];++++function byId(id) {++ return ADAPTERS.find((adapter) => adapter.id === id) || null;++}++++module.exports = { ADAPTERS, byId, assertAdapter, assertDriver };nnew file mode 100644@@@ -0,0 +1,33 @@++'use strict';++const { assertAdapter } = require('./contract');++++/**++ * LightCraft. The web build speaks the documented host bridge (lightcraft:open, lightcraft:cmd,++ * lightcraft:save) to a same-origin parent page, so files can be opened and exports can be handed++ * over to OpenVibe.++ */++const adapter = {++ id: 'light',++ title: 'LightCraft',++ base: {++ name: 'LightCraft',++ repository: 'https://github.com/storytold/lightcraft',++ tag: 'v0.19.0',++ licence: 'MIT OR Apache-2.0',++ licenceFiles: ['LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE', 'ATTRIBUTION.md'],++ upstream: 'https://github.com/storytold/lightcraft',++ builtBy: 'our CI (Rust stable, wasm32-unknown-unknown, cargo xtask web, wasm-bindgen 0.2.129)',++ },++ bundle: { path: 'light', entry: 'index.html', query: '?host=parent' },++ driver: 'lightcraft',++ capabilities: { open: true, save: true, commands: true },++ commands: [++ { id: 'library.info', title: 'Library info', description: 'What the app knows about your library: photo count, size and where it is kept.' },++ { id: 'library.state', title: 'Library state', description: 'The source, filter, sort and selection in the app right now.' },++ { id: 'photo.inspect', title: 'Active photo', description: 'What the app knows about the photo in view.' },++ { id: 'develop.get', title: 'Develop settings', description: 'The develop settings of the photo in view.' },++ { id: 'develop.controls', title: 'Develop controls', description: 'Every develop control with its current value.' },++ ],++};++++module.exports = assertAdapter(adapter);nnew file mode 100644@@@ -0,0 +1,27 @@++'use strict';++const { assertAdapter } = require('./contract');++++/**++ * PhotoCraft. The web build has no scriptable API of its own: files are opened by dropping them++ * on the app, and there is no export the page can ask for. Both limits are shown in the app, not++ * hidden.++ */++const adapter = {++ id: 'photo',++ title: 'PhotoCraft',++ base: {++ name: 'PhotoCraft',++ repository: 'https://github.com/storytold/photocraft',++ tag: 'v0.21.0',++ licence: 'MIT OR Apache-2.0',++ licenceFiles: ['LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE', 'ATTRIBUTION.md'],++ upstream: 'https://github.com/storytold/photocraft',++ builtBy: 'our CI (Rust stable, wasm32-unknown-unknown, Trunk 0.21.14)',++ },++ bundle: { path: 'photo', entry: 'index.html', query: '' },++ driver: 'photocraft',++ capabilities: { open: true, save: false, commands: false },++ commands: [],++};++++module.exports = assertAdapter(adapter);nnew file mode 100644@@@ -0,0 +1,87 @@++'use strict';++const express = require('express');++const { ADAPTERS } = require('./adapters');++++/** The Studio API: what the apps are, what a person owns, and the way in and out. */++function createApi({ sso, media, pics }) {++ const r = express.Router();++++ 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: 'studio.auth.required',++ detail: 'Sign in with OpenVibe to open and save files.',++ });++ 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 || 'OpenVibe request failed', status,++ code: e.code || 'studio.upstream_error', detail: e.detail || '',++ });++ });++++ r.get('/apps', (_req, res) => res.json({++ apps: ADAPTERS.map((adapter) => ({++ id: adapter.id, title: adapter.title, capabilities: adapter.capabilities,++ commands: adapter.commands, base: adapter.base,++ })),++ }));++++ r.get('/files', wrap(async (req, res) => {++ const value = token(req, res);++ if (!value) return;++ const out = { download: { files: [], folders: [], usage: null }, pics: { images: [] }, problems: [] };++ try {++ const data = await media.listFiles(value, { limit: req.query.limit });++ out.download = data;++ } catch (e) {++ out.problems.push({ source: 'download', code: e.code || 'media.upstream_error', detail: e.detail || e.message });++ }++ try {++ out.pics.images = await pics.listImages(value);++ } catch (e) {++ out.problems.push({ source: 'pics', code: e.code || 'pics.upstream_error', detail: e.detail || e.message });++ }++ res.json(out);++ }));++++ r.get('/files/:source/:id/bytes', wrap(async (req, res) => {++ const value = token(req, res);++ if (!value) return;++ const source = req.params.source;++ const id = req.params.id;++ const file = source === 'pics' ? await pics.bytes(id) : await media.downloadBytes(value, id);++ const name = String(file.name || 'file').replace(/[^A-Za-z0-9._-]+/g, '_');++ res.set('Content-Type', file.contentType || 'application/octet-stream');++ res.set('Content-Disposition', 'attachment; filename="' + name + '"');++ res.set('Cache-Control', 'private, no-store');++ res.send(file.bytes);++ }));++++ r.post('/save', express.raw({ type: '*/*', limit: '256mb' }), wrap(async (req, res) => {++ const value = token(req, res);++ if (!value) return;++ const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);++ if (!body.length) {++ res.status(422).type('application/problem+json').json({++ type: 'about:blank', title: 'Nothing to save', status: 422, code: 'studio.save.empty',++ });++ return;++ }++ const name = String(req.get('x-file-name') || 'export');++ const contentType = req.get('content-type') || 'application/octet-stream';++ const file = await media.uploadFile(value, { name: name, contentType: contentType, bytes: body });++ res.status(201).json({ file });++ }));++++ return r;++}++++module.exports = { createApi };nnew file mode 100644@@@ -0,0 +1,65 @@++'use strict';++const path = require('path');++const express = require('express');++const helmet = require('helmet');++const { createSsoClient } = require('openvibe-sdk/sso');++const { createApi } = require('./api');++const { createPages } = require('./pages');++const { createBundleFiles } = require('./bundle-files');++const { createMediaClient } = require('./openvibe/media');++const { createPicsClient } = require('./openvibe/pics');++const configLib = require('./config');++++function createApp(opts = {}) {++ const config = opts.config || configLib.load();++ const sso = opts.sso || createSsoClient({++ site: 'studio', baseUrl: config.baseUrl, clientId: config.clientId, clientSecret: config.clientSecret,++ networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl, secureCookies: config.secureCookies,++ });++ const media = opts.media || createMediaClient({ baseUrl: config.downloadUrl, fetchImpl: opts.fetchImpl });++ const pics = opts.pics || createPicsClient({ baseUrl: config.picsUrl, 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'", "'wasm-unsafe-eval'", '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.download', 'https://openvibe.pics'],++ frameSrc: ["'self'"],++ frameAncestors: ["'none'"],++ objectSrc: ["'none'"],++ workerSrc: ["'self'", 'blob:'],++ 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-studio', version: require('../package.json').version, drill_port: config.drillPort,++ }));++ app.get('/robots.txt', (_req, res) => res.type('text/plain').send('User-agent: *\nAllow: /\nDisallow: /api/\nSitemap: https://openvibe.studio/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.studio/</loc></url><url><loc>https://openvibe.studio/photo</loc></url>' +++ '<url><loc>https://openvibe.studio/light</loc></url><url><loc>https://openvibe.studio/attribution</loc></url></urlset>'));++ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'studio', service: 'studio', host: 'openvibe.studio', name: 'OpenVibe.Studio', profile: 'hosting' })); }++ app.get('/llms.txt', (_req, res) => res.type('text/plain').send(++ '# OpenVibe.Studio\n\nPhoto and Light: two open-source creative apps (PhotoCraft, LightCraft) built from pinned release tags and served inside an OpenVibe account.\n\n- [Home](/)\n- [Photo](/photo)\n- [Light](/light)\n- [Attribution](/attribution)\n- [API docs](/openapi.json)\n'));++ app.get('/openapi.json', (_req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'openapi.json')));++ app.use('/api/v1/studio', createApi({ sso, media, pics }));++ app.use('/shared', require('openvibe-shared/serve').handler());++ app.use('/apps', createBundleFiles({ root: config.bundleRoot }));++ app.use(express.static(path.join(__dirname, '..', 'public'), { index: false, maxAge: '1h' }));++ app.use(createPages({ config, sso, media, pics }));++ app.use((err, _req, res, _next) => {++ if (res.headersSent) return;++ res.status(err.status || 500).type('text/plain').send('Studio could not complete that request.');++ });++ return { app, config, sso, media, pics };++}++++module.exports = { createApp };nnew file mode 100644@@@ -0,0 +1,44 @@++'use strict';++const fs = require('fs');++const { bundleHeaders, bundlePath } = require('./bundles');++++/**++ * The app bundles are static files with rules of their own: the WebAssembly module needs the++ * application/wasm type, hashed parts may be cached forever and the loader may not be. The++ * bundles live outside git; the deploy fetches them from the build artifacts by tag and checksum.++ */++function createBundleFiles({ root }) {++ return (req, res, next) => {++ const rel = decodeURIComponent(req.path || '').replace(/^\/+/, '');++ const parts = rel.split('/').filter(Boolean);++ const appId = parts.shift() || '';++ const rest = parts.join('/');++ const file = bundlePath(root, appId, rest);++ if (!file) return next();++ let stat;++ try {++ stat = fs.statSync(file);++ } catch {++ return next();++ }++ if (!stat.isFile()) return next();++ const headers = bundleHeaders(appId, rest);++ for (const [key, value] of Object.entries(headers)) res.setHeader(key, value);++ const etag = '"' + stat.size.toString(16) + '-' + Math.floor(stat.mtimeMs).toString(16) + '"';++ res.setHeader('ETag', etag);++ res.setHeader('Last-Modified', stat.mtime.toUTCString());++ res.setHeader('Content-Length', stat.size);++ if (req.headers['if-none-match'] === etag) {++ res.status(304).end();++ return;++ }++ if (req.method === 'HEAD') {++ res.status(200).end();++ return;++ }++ res.status(200);++ fs.createReadStream(file).pipe(res);++ };++}++++module.exports = { createBundleFiles };nnew file mode 100644@@@ -0,0 +1,61 @@++'use strict';++const fs = require('fs');++const path = require('path');++++const MIME = {++ '.wasm': 'application/wasm',++ '.js': 'text/javascript; charset=utf-8',++ '.mjs': 'text/javascript; charset=utf-8',++ '.html': 'text/html; charset=utf-8',++ '.css': 'text/css; charset=utf-8',++ '.json': 'application/json; charset=utf-8',++ '.txt': 'text/plain; charset=utf-8',++ '.otf': 'font/otf',++ '.woff': 'font/woff',++ '.woff2': 'font/woff2',++ '.svg': 'image/svg+xml',++ '.png': 'image/png',++ '.jpg': 'image/jpeg',++ '.jpeg': 'image/jpeg',++ '.webp': 'image/webp',++ '.ico': 'image/x-icon',++};++++// A name that carries a content hash never changes; a plain name may change with every release.++const HASHED = /-[0-9a-f]{8,}./;++const PART = /.part-[0-9a-f]+$/;++++/**++ * The headers one file of a bundle is served with, following each upstream project's own rules++ * (packaging/web/_headers): PhotoCraft's hashed parts are immutable, everything else is++ * revalidated, and the WebAssembly module is served as application/wasm or the browser refuses++ * to stream-compile it.++ */++function bundleHeaders(appId, relPath) {++ const name = path.basename(relPath || '');++ const ext = path.extname(name).toLowerCase();++ const headers = { 'X-Content-Type-Options': 'nosniff' };++ if (MIME[ext]) headers['Content-Type'] = MIME[ext];++ const immutable = appId === 'photo' && (PART.test(name) || HASHED.test(name) || ext === '.wasm');++ headers['Cache-Control'] = immutable ? 'public, max-age=31536000, immutable' : 'no-cache';++ return headers;++}++++/** The file a request for `rel` inside a bundle maps to, or null when it would leave the bundle. */++function bundlePath(root, appId, rel) {++ const base = path.resolve(root, String(appId || ''));++ const target = path.resolve(base, String(rel || ''));++ if (target !== base && !target.startsWith(base + path.sep)) return null;++ return target;++}++++/** Whether a bundle is installed on this machine (the deploy fetches it; it is never in git). */++function hasBundle(root, appId) {++ try {++ return fs.statSync(path.join(root, appId, 'index.html')).isFile();++ } catch {++ return false;++ }++}++++module.exports = { bundleHeaders, bundlePath, hasBundle, MIME };nnew file mode 100644@@@ -0,0 +1,25 @@++'use strict';++require('dotenv').config();++const trim = (s) => String(s || '').replace(/\/+$/, '');++const path = require('path');++++function load(env = process.env) {++ const port = Number.parseInt(env.PORT, 10) || 5155;++ 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}`),++ drillPort: Number.parseInt(env.DRILL_PORT, 10) || port + 10000,++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,++ networkUrl,++ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),++ downloadUrl: trim(env.OV_DOWNLOAD_URL || 'https://openvibe.download'),++ picsUrl: trim(env.OV_PICS_URL || 'https://openvibe.pics'),++ clientId: env.OV_OAUTH_CLIENT_ID || 'studio',++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',++ secureCookies: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : env.NODE_ENV === 'production',++ bundleRoot: env.STUDIO_BUNDLE_ROOT || path.join(__dirname, '..', 'public', 'apps'),++ };++}++module.exports = { load };nnew file mode 100644@@@ -0,0 +1,3 @@++'use strict';++const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);++module.exports = { esc };nnew file mode 100644@@@ -0,0 +1,4 @@++'use strict';++const { createApp } = require('./app');++const { app, config } = createApp();++app.listen(config.port, config.host, () => console.log(`[Studio] listening on ${config.host}:${config.port} (drill ${config.drillPort})`));nnew file mode 100644@@@ -0,0 +1,17 @@++'use strict';++const shell = require('openvibe-shared/shell');++const serve = require('openvibe-shared/serve');++const NAV = [++ { label: 'Home', href: '/' },++ { label: 'Photo', href: '/photo' },++ { label: 'Light', href: '/light' },++];++function page({ title, body, viewer, config, path = '/' }) {++ const nav = { service: 'studio', 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: 'studio', variant: 'full', brandName: 'OpenVibe.Studio' };++ return shell.page({ name: 'OpenVibe.Studio', lang: 'en', title: `${title} · OpenVibe.Studio`, titleSuffix: '', siteName: 'OpenVibe.Studio', description: 'Photo and Light, the creative apps on the OpenVibe network.', 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/studio.css"><meta name="referrer" content="strict-origin-when-cross-origin">`,++ body: `<div id="navbar-mount"></div><main id="main" class="studio-page">${body}</main>` });++}++function send(res, opts, status = 200) { res.set('Cache-Control', opts.viewer?.kind === 'user' ? 'private, no-store' : 'public, max-age=60'); res.set('Vary', 'Cookie'); res.status(status).type('html').send(page(opts)); }++module.exports = { page, send, NAV };nnew file mode 100644@@@ -0,0 +1,94 @@++'use strict';++++class StudioError extends Error {++ constructor(status, code, detail) {++ super(detail || code);++ this.status = status || 502;++ this.code = code || 'studio.upstream_error';++ this.detail = detail || '';++ }++}++++/**++ * OpenVibe.Download (MediaHub): the files a person owns, and the upload that puts a file there.++ * The API accepts a person's Network token as a Bearer; the byte route for a file does not, so++ * bytes come through a public share link (see downloadBytes).++ */++function createMediaClient({ baseUrl, fetchImpl = globalThis.fetch }) {++ const root = String(baseUrl || '').replace(/\/+$/, '');++++ async function request(token, path, options = {}) {++ const res = await fetchImpl(root + '/api/v1' + path, {++ method: options.method || 'GET',++ headers: Object.assign({ authorization: 'Bearer ' + token, accept: 'application/json' }, options.headers || {}),++ ...(options.body !== undefined ? { body: options.body } : {}),++ });++ if (!res.ok) {++ const data = await res.json().catch(() => ({}));++ throw new StudioError(res.status, data.code || 'media.upstream_error', data.detail || data.title || 'OpenVibe.Download returned ' + res.status);++ }++ return res.json().catch(() => ({}));++ }++++ return {++ root,++ async listFiles(token, options = {}) {++ const limit = Math.min(200, Math.max(1, Number(options.limit) || 50));++ const data = await request(token, '/files?limit=' + encodeURIComponent(limit));++ return {++ files: Array.isArray(data.files) ? data.files : [],++ folders: Array.isArray(data.folders) ? data.folders : [],++ usage: data.usage || null,++ };++ },++ async getFile(token, id) {++ const data = await request(token, '/files/' + encodeURIComponent(id));++ return { file: data.file || null, shares: Array.isArray(data.shares) ? data.shares : [] };++ },++ async downloadBytes(token, id) {++ const info = await this.getFile(token, id);++ const file = info.file;++ if (!file) throw new StudioError(404, 'media.file_not_found', 'No such file in your OpenVibe.Download drive.');++ const share = info.shares.find((one) => one && (one.public === true || one.state === 'public'));++ if (!share) {++ throw new StudioError(501, 'media.bytes.unavailable',++ 'OpenVibe.Download has no route that hands a file\'s bytes to a person\'s token yet. Share the file publicly and it can be opened here.');++ }++ const res = await fetchImpl(root + '/s/' + encodeURIComponent(share.slug) + '/f/' + encodeURIComponent(file.id) + '/download', { headers: { accept: '*/*' } });++ if (!res.ok) throw new StudioError(res.status, 'media.bytes.unavailable', 'OpenVibe.Download returned ' + res.status + ' for the share link.');++ return { name: file.name, contentType: file.content_type || 'application/octet-stream', bytes: Buffer.from(await res.arrayBuffer()) };++ },++ async uploadFile(token, file) {++ const bytes = Buffer.isBuffer(file.bytes) ? file.bytes : Buffer.from(file.bytes || []);++ const name = String(file.name || 'export');++ const contentType = file.contentType || 'application/octet-stream';++ const start = await request(token, '/files', {++ method: 'POST',++ headers: { 'content-type': 'application/json' },++ body: JSON.stringify({ name: name, size: bytes.length, content_type: contentType }),++ });++ const upload = start.upload || {};++ if (!upload.id) throw new StudioError(502, 'media.upload.unavailable', 'OpenVibe.Download did not start an upload.');++ const partSize = Number(upload.part_size) || bytes.length || 1;++ const expected = Number(upload.parts_expected) || 1;++ const missing = Array.isArray(upload.missing) && upload.missing.length ? upload.missing : Array.from({ length: expected }, (_, index) => index + 1);++ for (const part of missing) {++ const from = (part - 1) * partSize;++ const to = Math.min(bytes.length, from + partSize);++ await request(token, '/uploads/' + encodeURIComponent(upload.id) + '/parts/' + part, {++ method: 'PUT',++ headers: { 'content-type': 'application/octet-stream' },++ body: bytes.subarray(from, to),++ });++ }++ const done = await request(token, '/uploads/' + encodeURIComponent(upload.id) + '/complete', {++ method: 'POST',++ headers: { 'content-type': 'application/json' },++ body: '{}',++ });++ return done.file || { name: name, size: bytes.length };++ },++ };++}++++module.exports = { createMediaClient, StudioError };nnew file mode 100644@@@ -0,0 +1,51 @@++'use strict';++const { StudioError } = require('./media');++++/**++ * OpenVibe.Pics: the pictures a person owns. The image bytes are public (the site serves them++ * under /i/<slug>), so a picture can be fetched without the owner's session.++ */++function createPicsClient({ baseUrl, fetchImpl = globalThis.fetch }) {++ const root = String(baseUrl || '').replace(/\/+$/, '');++++ async function request(path, token) {++ const res = await fetchImpl(root + '/api/v1' + path, {++ headers: Object.assign({ accept: 'application/json' }, token ? { authorization: 'Bearer ' + token } : {}),++ });++ if (!res.ok) {++ const data = await res.json().catch(() => ({}));++ throw new StudioError(res.status, data.code || 'pics.upstream_error', data.detail || data.title || 'OpenVibe.Pics returned ' + res.status);++ }++ return res.json().catch(() => ({}));++ }++++ const wire = (image) => ({++ id: image.slug || image.id || null,++ slug: image.slug || null,++ name: image.name || image.title || image.slug || 'image',++ contentType: image.content_type || 'image/jpeg',++ direct: (image.links && image.links.direct) || null,++ page: (image.links && image.links.page) || null,++ thumb: (image.links && image.links.thumb) || null,++ });++++ return {++ root,++ async listImages(token) {++ const data = await request('/me/images', token);++ const images = Array.isArray(data.images) ? data.images : (Array.isArray(data) ? data : []);++ return images.map(wire);++ },++ async bytes(slug) {++ const data = await request('/images/' + encodeURIComponent(slug));++ const image = wire(data.image || data);++ const direct = image.direct || (image.slug ? root + '/i/' + image.slug : null);++ if (!direct) throw new StudioError(404, 'pics.image_not_found', 'No such picture in OpenVibe.Pics.');++ const res = await fetchImpl(direct, { headers: { accept: '*/*' } });++ if (!res.ok) throw new StudioError(res.status, 'pics.bytes.unavailable', 'OpenVibe.Pics returned ' + res.status + ' for the picture.');++ return { name: image.name, contentType: image.contentType, bytes: Buffer.from(await res.arrayBuffer()) };++ },++ };++}++++module.exports = { createPicsClient };nnew file mode 100644@@@ -0,0 +1,106 @@++'use strict';++const express = require('express');++const { esc } = require('./html');++const { send } = require('./layout');++const { ADAPTERS } = require('./adapters');++const { hasBundle } = require('./bundles');++const { APPS } = require('./product');++++function createPages({ config, sso }) {++ const r = express.Router();++ const viewerOf = (req) => req.user || req.viewer || { kind: 'anonymous' };++++ // The home is short (Opus's design pass, 11-08): one promise, two apps with three features each, three plain notes,++ // one credit line. The measured detail lives on each app's page and in the docs, not here.++ const HOME_APPS = [++ { id: 'photo', name: 'Photo', line: 'Layers, masks and retouching.', features: ['Layers and masks', 'Curves and adjustments', 'Opens PSD files'] },++ { id: 'light', name: 'Light', line: 'Your photo library and RAW developer.', features: ['RAW files', 'Develop and presets', 'A library in your browser'] },++ ];++ const appCard = (app) => '<article class="app-card"><h3>' + esc(app.name) + '</h3><p>' + esc(app.line) + '</p>' +++ '<ul class="app-features">' + app.features.map((f) => '<li>' + esc(f) + '</li>').join('') + '</ul>' +++ '<p><a class="button-primary" href="/' + esc(app.id) + '">Open ' + esc(app.name) + '</a></p></article>';++++ r.get('/', (req, res) => {++ const viewer = viewerOf(req);++ const photo = APPS.find((x) => x.id === 'photo'), light = APPS.find((x) => x.id === 'light');++ const body = '<section class="hero"><div>' +++ '<span class="eyebrow">OPENVIBE STUDIO</span>' +++ '<h1>Edit photos right in your browser.</h1>' +++ '<p>Photo for layers and retouching. Light for your library and RAW files. Free and open source, with your files on OpenVibe.</p>' +++ '<p><a class="button-primary" href="/photo">Open Photo</a> <a class="button-secondary" href="/light">Open Light</a></p>' +++ '</div></section>' +++ '<section id="apps"><div class="app-grid">' + HOME_APPS.map(appCard).join('') + '</div></section>' +++ '<section id="limits"><h2>Good to know</h2><ul class="limit-list">' +++ '<li>The first open takes a few seconds while the app loads.</li>' +++ '<li>On a phone with 4 GB of memory, very large files can run out of memory.</li>' +++ '<li>It needs a connection: there is no offline mode yet. Video editing comes later.</li></ul></section>' +++ '<section id="licences"><p class="fine-print">Photo is <a href="' + esc(photo.upstream) + '" rel="noopener">PhotoCraft</a> and Light is ' +++ '<a href="' + esc(light.upstream) + '" rel="noopener">LightCraft</a>, open-source apps (' + esc(photo.licence) + ') that we build from their releases. ' +++ '<a href="/attribution">Attribution</a></p></section>';++ send(res, { title: 'Studio: edit photos in your browser', body: body, viewer: viewer, config: config, path: '/' });++ });++++ const appPage = (adapter, viewer) => {++ const installed = hasBundle(config.bundleRoot, adapter.id);++ const src = '/apps/' + adapter.bundle.path + '/' + adapter.bundle.entry + (adapter.bundle.query || '');++ const commands = adapter.commands.map((command) =>++ '<button class="button-quiet" type="button" data-command="' + esc(command.id) + '" title="' + esc(command.description || command.title) + '">' + esc(command.title) + '</button>').join('');++ const saveNote = adapter.capabilities.save ? '' :++ 'Save to OpenVibe is not available for this app: its browser build has no scriptable export, so export from the app and the file downloads to your browser.';++ const head = '<section class="app-head"><div>' +++ '<span class="eyebrow">' + esc(adapter.title) + '</span>' +++ '<h1>' + esc(adapter.title) + '</h1>' +++ '<p>Build ' + esc(adapter.base.tag) + ' of ' + esc(adapter.base.repository.replace('https://github.com/', '')) +++ ', built by ' + esc(adapter.base.builtBy) + '. Licence: ' + esc(adapter.base.licence) + '.</p>' +++ '</div><div class="app-actions">' +++ '<button class="button-secondary" type="button" data-action="open">Open from OpenVibe</button>' +++ '<button class="button-secondary" type="button" data-action="save"' + (adapter.capabilities.save ? '' : ' disabled') + '>Save to OpenVibe</button>' +++ (commands ? '<span class="app-commands">' + commands + '</span>' : '') +++ '</div>' +++ '<p class="app-status" data-role="status" aria-live="polite"></p>' +++ (saveNote ? '<p class="fine-print">' + esc(saveNote) + '</p>' : '') +++ '</section>';++ const files = '<div class="app-files" data-role="files" hidden><h2>Your OpenVibe files</h2>' +++ '<ul data-role="file-list"></ul><p data-role="files-note" class="fine-print"></p></div>' +++ '<pre class="app-output" data-role="output" hidden></pre>';++ const frame = installed++ ? '<div class="app-frame-wrap"><iframe class="app-frame" src="' + esc(src) + '" title="' + esc(adapter.title) +++ '" allow="fullscreen; clipboard-read; clipboard-write" allowfullscreen></iframe></div>'++ : '<p class="app-missing">The ' + esc(adapter.title) + ' bundle is not installed on this server yet. ' +++ 'The deploy fetches it from the build artifacts by tag and checksum; a bundle is never kept in git.</p>';++ const scripts = '<script src="/js/drivers.js"></script><script src="/js/studio.js"></script>';++ return '<section class="app-shell" data-studio-app="' + esc(adapter.id) + '" data-studio-driver="' + esc(adapter.driver) +++ '" data-studio-signed-in="' + (viewer.kind === 'user' ? 'true' : 'false') + '">' + head + files + frame + '</section>' + scripts;++ };++++ r.get('/:app(photo|light)', (req, res) => {++ const adapter = ADAPTERS.find((one) => one.id === req.params.app);++ if (!adapter) {++ send(res, {++ title: 'No such app',++ body: '<section class="page-intro"><h1>No such app</h1><p><a href="/">Back to Studio</a></p></section>',++ viewer: viewerOf(req), config: config, path: '/',++ }, 404);++ return;++ }++ const viewer = viewerOf(req);++ send(res, { title: adapter.title + ' in Studio', body: appPage(adapter, viewer), viewer: viewer, config: config, path: '/' + adapter.id });++ });++++ r.get('/attribution', (req, res) => {++ const rows = ADAPTERS.map((adapter) => '<li><strong>' + esc(adapter.base.name) + '</strong> — ' + esc(adapter.base.licence) +++ '. Built by ' + esc(adapter.base.builtBy) + ' from <a href="' + esc(adapter.base.repository) + '" rel="noopener">' + esc(adapter.base.repository) +++ '</a> at tag ' + esc(adapter.base.tag) + '. Licence files kept with the build: ' +++ esc(adapter.base.licenceFiles.join(', ')) + '.</li>').join('');++ const body = '<section class="page-intro"><span class="eyebrow">CREDIT</span><h1>Attribution</h1>' +++ '<p>Studio hosts builds of open-source projects. Each one keeps its own licence and its own attribution files; here they are in full.</p></section>' +++ '<ul class="limit-list">' + rows + '</ul>' +++ '<p class="fine-print">OpenVibe.Studio is MIT licensed. The apps are the work of their own authors and are used under their own terms; ' +++ 'OpenVibe.Studio hosts a build of them and is not otherwise connected to the upstream projects.</p>';++ send(res, { title: 'Attribution', body: body, viewer: viewerOf(req), config: config, path: '/attribution' });++ });++++ return r;++}++++module.exports = { createPages };nnew file mode 100644@@@ -0,0 +1,49 @@++'use strict';++++/** What the home page says about each app. The limits are measured, not guessed. */++const APPS = [++ {++ id: 'photo',++ title: 'PhotoCraft',++ tagline: 'A layered photo editor, in the browser.',++ what: 'PhotoCraft is an open-source photo editor with layers, masks, curves and RAW development. Studio serves a build of it inside an OpenVibe page: sign in, drop in a file from your OpenVibe library, and work.',++ limits: [++ 'Heavy first load. The app ships its module as gzip parts, so the first frame takes seconds on a slow line.',++ 'Memory. A 4 GB Photoshop file passed the app\'s 4 GB cap but used 2.7-3.0 GB of memory; a phone with 4 GB can run out of memory on big files.',++ 'No scriptable export in the browser build. Save to OpenVibe is not offered for Photo: export from the app and the file downloads to your browser.',++ ],++ upstream: 'https://github.com/storytold/photocraft',++ licence: 'MIT OR Apache-2.0',++ licenceNote: 'The upstream repository ships LICENSE-MIT and LICENSE-APACHE (your choice), plus NOTICE and ATTRIBUTION.md. Our build keeps those files.',++ },++ {++ id: 'light',++ title: 'LightCraft',++ tagline: 'A photo library and RAW developer, in the browser.',++ what: 'LightCraft is an open-source photo library and RAW developer: a catalogue, develop controls and exports, with the library kept in your own browser storage. Studio serves a build of it inside an OpenVibe page, so a photo can come from your OpenVibe library and an export can go back to it.',++ limits: [++ 'Heavy first load. The module is about 13.5 MB before compression (about 3.1 MB over Brotli) and is compiled before the first frame.',++ 'Our build leaves out the optional 16 MB Chinese font, so Chinese and Japanese text has no glyphs. Everything else works.',++ 'The library lives in your browser\'s storage for this site. Clearing site data empties it; keep your originals elsewhere.',++ 'Online only. Neither app caches for offline use, and neither works from a file:// page.',++ ],++ upstream: 'https://github.com/storytold/lightcraft',++ licence: 'MIT OR Apache-2.0',++ licenceNote: 'The upstream repository ships LICENSE-MIT and LICENSE-APACHE (your choice), plus NOTICE and ATTRIBUTION.md. Our build keeps those files.',++ },++];++++/** The limits both apps share, measured on the reference machine in the Studio due diligence. */++const LIMITS = [++ 'Heavy first load: both apps are native editors compiled to WebAssembly. Measured time to first frame was about 2.5 s at 1280 px and 0.6 s at 390 px for LightCraft, and about 1.3 s for PhotoCraft once the loader path is fixed.',++ 'Phones with 4 GB of RAM can run out of memory on big files. Both apps run on a phone; neither is light.',++ 'Works online only. Neither app ships a service worker, so nothing loads without a connection.',++ 'Cut (FilmCraft) is not part of Studio v0. It stays a desktop and agent tool.',++ 'No fake pictures: this page shows a capture of an app only when it comes from a running build.',++];++++function appById(id) {++ return APPS.find((app) => app.id === id) || null;++}++++module.exports = { APPS, LIMITS, appById };nnew file mode 100644@@@ -0,0 +1,128 @@++'use strict';++const test = require('node:test');++const assert = require('node:assert/strict');++const { ADAPTERS, byId, assertAdapter, assertDriver } = require('../server/adapters');++const drivers = require('../public/js/drivers.js');++++test('every adapter keeps the same contract, so a base can be swapped', () => {++ assert.equal(ADAPTERS.length, 2);++ for (const adapter of ADAPTERS) {++ assert.doesNotThrow(() => assertAdapter(adapter));++ assert.ok(adapter.base.repository.startsWith('https://github.com/'));++ assert.match(adapter.base.tag, /^v\d+\.\d+\.\d+$/);++ assert.ok(adapter.base.licenceFiles.length > 0);++ assert.ok(adapter.base.builtBy.length > 0);++ assert.equal(typeof adapter.bundle.path, 'string');++ assert.equal(typeof adapter.bundle.entry, 'string');++ }++ assert.equal(byId('light').title, 'LightCraft');++ assert.equal(byId('nope'), null);++});++++test('an adapter that is missing a part of the contract is refused', () => {++ const broken = Object.assign({}, byId('light'));++ delete broken.base;++ assert.throws(() => assertAdapter(broken), /base/);++ const noCommands = Object.assign({}, byId('light'), { commands: [{ id: 'x' }] });++ assert.throws(() => assertAdapter(noCommands), /command/);++});++++function fakeAppWindow() {++ const events = [];++ const dropped = [];++ class FakeFile {++ constructor(parts, name, options) { this.parts = parts; this.name = name; this.type = (options || {}).type; }++ }++ class FakeDataTransfer {++ constructor() { this.items = { add: (file) => dropped.push(file) }; }++ }++ class FakeDragEvent {++ constructor(type, init) { this.type = type; this.dataTransfer = (init || {}).dataTransfer; }++ }++ return {++ events, dropped,++ DataTransfer: FakeDataTransfer,++ File: FakeFile,++ DragEvent: FakeDragEvent,++ document: { dispatchEvent: (event) => { events.push(event); return true; } },++ };++}++++function fakeCtx(overrides = {}) {++ const sent = [];++ const win = fakeAppWindow();++ const ctx = {++ origin: 'https://studio.test',++ window: { postMessage: (message) => sent.push(message) },++ iframe: { contentWindow: null },++ nextSave: async () => ({ ok: true, file: { name: 'export.jpg', bytes: new Uint8Array([1, 2, 3]) } }),++ nextResponse: async () => ({ ok: true, value: '{"photos":3}' }),++ };++ return Object.assign(ctx, overrides, { sent, win });++}++++test('every driver keeps the same contract', () => {++ for (const [name, make] of Object.entries(drivers)) {++ const driver = make();++ assert.doesNotThrow(() => assertDriver(driver));++ assert.equal(driver.id, name);++ }++});++++test('the light driver opens a file through the documented bridge', async () => {++ const ctx = fakeCtx();++ const result = await drivers.lightcraft().open(ctx, { name: 'sunset.jpg', bytes: new Uint8Array([1, 2, 3]) });++ assert.equal(result.ok, true);++ assert.equal(ctx.sent.length, 1);++ assert.equal(ctx.sent[0].type, 'lightcraft:open');++ assert.equal(ctx.sent[0].name, 'sunset.jpg');++ assert.ok(ctx.sent[0].bytes instanceof Uint8Array);++});++++test('the light driver asks the app to export and waits for the file the app hands over', async () => {++ const ctx = fakeCtx();++ const result = await drivers.lightcraft().save(ctx);++ assert.equal(result.ok, true);++ assert.equal(result.file.name, 'export.jpg');++ assert.equal(ctx.sent[0].type, 'lightcraft:cmd');++ assert.equal(ctx.sent[0].id, 'app.export');++});++++test('the light driver reports a command that failed, with the app\'s own words', async () => {++ const ctx = fakeCtx({ nextResponse: async () => ({ ok: false, value: 'unknown command' }) });++ const result = await drivers.lightcraft().run(ctx, 'library.info', {});++ assert.equal(result.ok, false);++ assert.match(result.detail, /unknown command/);++ assert.equal(ctx.sent[0].id, 'library.info');++ assert.equal(ctx.sent[0].params, '{}');++});++++test('the photo driver drops the file on the app and says it cannot save', async () => {++ const ctx = fakeCtx();++ const win = fakeAppWindow();++ ctx.window = win;++ const result = await drivers.photocraft().open(ctx, { name: 'sunset.jpg', bytes: new Uint8Array([1, 2, 3]) });++ assert.equal(result.ok, true);++ assert.deepEqual(win.events.map((event) => event.type), ['dragenter', 'dragover', 'drop']);++ assert.equal(win.dropped.length, 1);++ assert.equal(win.dropped[0].name, 'sunset.jpg');++ const save = await drivers.photocraft().save(ctx);++ assert.equal(save.ok, false);++ assert.equal(save.code, 'photo.save.unsupported');++ const run = await drivers.photocraft().run(ctx, 'library.info', {});++ assert.equal(run.ok, false);++});++++test('a fake app is enough to drive every driver, and each one answers', async () => {++ for (const [name, make] of Object.entries(drivers)) {++ const ctx = fakeCtx();++ ctx.window = Object.assign(fakeAppWindow(), { postMessage: (message) => ctx.sent.push(message) });++ const driver = make();++ const opened = await driver.open(ctx, { name: 'a.jpg', bytes: new Uint8Array([9]) });++ assert.equal(typeof opened.ok, 'boolean', name + ' open');++ const saved = await driver.save(ctx);++ assert.equal(typeof saved.ok, 'boolean', name + ' save');++ const ran = await driver.run(ctx, 'library.info', {});++ assert.equal(typeof ran.ok, 'boolean', name + ' run');++ }++});nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++const test = require('node:test');++const assert = require('node:assert/strict');++const { createMediaClient } = require('../server/openvibe/media');++++function fakeFetch(routes) {++ const seen = [];++ const fetchImpl = async (url, options = {}) => {++ seen.push([url, options.method || 'GET', options.body ? Buffer.from(options.body).toString('utf8') : null]);++ const route = routes[url];++ if (!route) return { ok: false, status: 404, json: async () => ({ detail: 'no route' }) };++ return typeof route === 'function' ? route(options) : route;++ }++ return { fetchImpl, seen };++}++++const ok = (body, status = 200) => ({ ok: true, status, json: async () => body });++++test('the files of a person are listed with the bearer token', async () => {++ const { fetchImpl, seen } = fakeFetch({++ 'https://openvibe.download/api/v1/files?limit=10': ok({ files: [{ id: 'f1', name: 'a.jpg' }], folders: [], usage: { bytes: 3 } }),++ });++ const media = createMediaClient({ baseUrl: 'https://openvibe.download', fetchImpl });++ const data = await media.listFiles('person-token', { limit: 10 });++ assert.equal(data.files[0].name, 'a.jpg');++ assert.equal(seen[0][1], 'GET');++});++++test('a save is a start, the parts and a complete', async () => {++ const { fetchImpl, seen } = fakeFetch({++ 'https://openvibe.download/api/v1/files': ok({ upload: { id: 'up1', part_size: 4, parts_expected: 2, missing: [1, 2] } }, 201),++ 'https://openvibe.download/api/v1/uploads/up1/parts/1': ok({}),++ 'https://openvibe.download/api/v1/uploads/up1/parts/2': ok({}),++ 'https://openvibe.download/api/v1/uploads/up1/complete': ok({ file: { id: 'file_new', name: 'export.jpg' } }),++ });++ const media = createMediaClient({ baseUrl: 'https://openvibe.download', fetchImpl });++ const file = await media.uploadFile('person-token', { name: 'export.jpg', contentType: 'image/jpeg', bytes: Buffer.from('abcdefgh') });++ assert.equal(file.name, 'export.jpg');++ const paths = seen.map((entry) => entry[0] + ' ' + entry[1]);++ assert.deepEqual(paths, [++ 'https://openvibe.download/api/v1/files POST',++ 'https://openvibe.download/api/v1/uploads/up1/parts/1 PUT',++ 'https://openvibe.download/api/v1/uploads/up1/parts/2 PUT',++ 'https://openvibe.download/api/v1/uploads/up1/complete POST',++ ]);++ assert.equal(seen[1][2], 'abcd');++ assert.equal(seen[2][2], 'efgh');++});++++test('the bytes of a file come through a public share, or the layer says so', async () => {++ const { fetchImpl } = fakeFetch({++ 'https://openvibe.download/api/v1/files/f1': ok({ file: { id: 'f1', name: 'a.jpg', content_type: 'image/jpeg' }, shares: [] }),++ });++ const media = createMediaClient({ baseUrl: 'https://openvibe.download', fetchImpl });++ await assert.rejects(() => media.downloadBytes('person-token', 'f1'), (error) => {++ assert.equal(error.status, 501);++ assert.equal(error.code, 'media.bytes.unavailable');++ assert.match(error.detail, /[Ss]hare/);++ return true;++ });++++ const shared = fakeFetch({++ 'https://openvibe.download/api/v1/files/f1': ok({ file: { id: 'f1', name: 'a.jpg', content_type: 'image/jpeg' }, shares: [{ slug: 'abc', public: true }] }),++ 'https://openvibe.download/s/abc/f/f1/download': { ok: true, status: 200, arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer },++ });++ const client = createMediaClient({ baseUrl: 'https://openvibe.download', fetchImpl: shared.fetchImpl });++ const file = await client.downloadBytes('person-token', 'f1');++ assert.equal(file.name, 'a.jpg');++ assert.equal(file.bytes.length, 3);++});nnew file mode 100644@@@ -0,0 +1,188 @@++'use strict';++const test = require('node:test');++const assert = require('node:assert/strict');++const http = require('node:http');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const express = require('express');++const { createApp } = require('../server/app');++++const config = {++ port: 0, host: '127.0.0.1', baseUrl: 'http://studio.test', networkUrl: 'https://openvibe.network',++ networkInternalUrl: 'http://127.0.0.1:4000', downloadUrl: 'https://openvibe.download', picsUrl: 'https://openvibe.pics',++ clientId: 'studio', clientSecret: '', secureCookies: false, trustProxy: 2, drillPort: 15155, bundleRoot: '',++};++++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 match = (req.headers.cookie || '').match(/(?:^|;\s*)ov_token=([^;]+)/);++ return (match && match[1]) || null;++ },++ };++}++++const fakeMedia = {++ calls: [],++ listFiles: async (token) => { fakeMedia.calls.push(['list', token]); return { files: [{ id: 'file_a', name: 'sunset.jpg', content_type: 'image/jpeg', size: 12 }], folders: [], usage: { bytes: 12 } }; },++ downloadBytes: async (token, id) => { fakeMedia.calls.push(['bytes', token, id]); throw Object.assign(new Error('no byte route'), { status: 501, code: 'media.bytes.unavailable', detail: 'no public share' }); },++ uploadFile: async (token, file) => { fakeMedia.calls.push(['upload', token, file.name, file.bytes.length]); return { id: 'file_new', name: file.name, size: file.bytes.length }; },++};++++const fakePics = {++ listImages: async () => [{ id: 'img_a', slug: 'img_a', name: 'mountains.jpg' }],++ bytes: async () => ({ name: 'mountains.jpg', contentType: 'image/jpeg', bytes: Buffer.from([1, 2, 3]) }),++};++++function bundleFixture() {++ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'studio-bundles-'));++ fs.mkdirSync(path.join(root, 'light'), { recursive: true });++ fs.mkdirSync(path.join(root, 'photo', 'app'), { recursive: true });++ fs.writeFileSync(path.join(root, 'light', 'index.html'), '<!doctype html><title>LightCraft</title>');++ fs.writeFileSync(path.join(root, 'light', 'lightcraft_web_bg.wasm'), Buffer.from([0, 97, 115, 109]));++ fs.writeFileSync(path.join(root, 'photo', 'index.html'), '<!doctype html><title>PhotoCraft</title>');++ fs.writeFileSync(path.join(root, 'photo', 'app', 'parts.txt'), 'photocraft-parts 1\n');++ fs.writeFileSync(path.join(root, 'photo', 'app', 'photocraft-web.js'), 'export {};\n');++ fs.writeFileSync(path.join(root, 'photo', 'app', 'photocraft-web-abcdef12.part-0'), 'part');++ return root;++}++++async function serve(opts = {}) {++ const { app } = createApp({++ config: Object.assign({}, config, { bundleRoot: bundleFixture() }, opts.config || {}),++ sso: fakeSso(),++ media: opts.media || fakeMedia,++ pics: opts.pics || fakePics,++ });++ const server = http.createServer(app);++ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));++ return { base: 'http://127.0.0.1:' + server.address().port, close: () => new Promise((resolve) => server.close(resolve)) };++}++++test('the home names both apps, their limits and their licences', async (t) => {++ const s = await serve(); t.after(s.close);++ const res = await fetch(s.base);++ assert.equal(res.status, 200);++ const html = await res.text();++ assert.match(html, /PhotoCraft/);++ assert.match(html, /LightCraft/);++ assert.match(html, /Good to know/);++ assert.match(html, /MIT OR Apache-2\.0/);++ assert.match(html, /github\.com\/storytold\/photocraft/);++ assert.match(html, /github\.com\/storytold\/lightcraft/);++ assert.match(html, /Video editing comes later/);++ assert.match(html, /4 GB/);++ assert.doesNotMatch(html, /<img/);++});++++test('the app pages wrap the app in the layer, with the sign-in state', async (t) => {++ const s = await serve(); t.after(s.close);++ const guest = await fetch(s.base + '/light');++ const guestHtml = await guest.text();++ assert.equal(guest.status, 200);++ assert.match(guestHtml, /Open from OpenVibe/);++ assert.match(guestHtml, /Save to OpenVibe/);++ assert.match(guestHtml, /data-studio-app="light"/);++ assert.match(guestHtml, /data-studio-driver="lightcraft"/);++ assert.match(guestHtml, /data-studio-signed-in="false"/);++ assert.match(guestHtml, /apps\/light\/index\.html\?host=parent/);++++ const person = await fetch(s.base + '/light', { headers: { cookie: 'ov_token=person-a' } });++ assert.match(await person.text(), /data-studio-signed-in="true"/);++++ const photo = await fetch(s.base + '/photo');++ const photoHtml = await photo.text();++ assert.match(photoHtml, /data-studio-driver="photocraft"/);++ assert.match(photoHtml, /no scriptable export/);++});++++test('the bundles are served with the type and the cache rules the apps need', async (t) => {++ const s = await serve(); t.after(s.close);++ const wasm = await fetch(s.base + '/apps/light/lightcraft_web_bg.wasm');++ assert.equal(wasm.status, 200);++ assert.match(wasm.headers.get('content-type'), /application\/wasm/);++ assert.equal(wasm.headers.get('cache-control'), 'no-cache');++++ const part = await fetch(s.base + '/apps/photo/app/photocraft-web-abcdef12.part-0');++ assert.equal(part.status, 200);++ assert.match(part.headers.get('cache-control'), /immutable/);++++ const loader = await fetch(s.base + '/apps/photo/app/photocraft-web.js');++ assert.equal(loader.headers.get('cache-control'), 'no-cache');++ assert.match(loader.headers.get('content-type'), /javascript/);++++ const list = await fetch(s.base + '/apps/photo/app/parts.txt');++ assert.equal(list.headers.get('cache-control'), 'no-cache');++ assert.match(list.headers.get('content-type'), /text\/plain/);++++ const page = await fetch(s.base + '/apps/photo/index.html');++ assert.equal(page.headers.get('cache-control'), 'no-cache');++++ assert.equal((await fetch(s.base + '/apps/light/nothing-here')).status, 404);++ assert.equal((await fetch(s.base + '/apps/../package.json')).status, 404);++});++++test('saving needs a signed-in person', async (t) => {++ fakeMedia.calls.length = 0;++ const s = await serve(); t.after(s.close);++ const guest = await fetch(s.base + '/api/v1/studio/save', { method: 'POST', headers: { 'content-type': 'image/jpeg' }, body: 'bytes' });++ assert.equal(guest.status, 401);++ assert.equal(fakeMedia.calls.filter((call) => call[0] === 'upload').length, 0);++++ const person = await fetch(s.base + '/api/v1/studio/save', {++ method: 'POST',++ headers: { cookie: 'ov_token=person-a', 'content-type': 'image/jpeg', 'x-file-name': 'export.jpg' },++ body: 'bytes',++ });++ assert.equal(person.status, 201);++ const upload = fakeMedia.calls.find((call) => call[0] === 'upload');++ assert.deepEqual(upload, ['upload', 'person-a', 'export.jpg', 5]);++});++++test('the files listing uses the person token and reports a source that fails', async (t) => {++ fakeMedia.calls.length = 0;++ const s = await serve(); t.after(s.close);++ assert.equal((await fetch(s.base + '/api/v1/studio/files')).status, 401);++ const res = await fetch(s.base + '/api/v1/studio/files', { headers: { cookie: 'ov_token=person-a' } });++ assert.equal(res.status, 200);++ const data = await res.json();++ assert.equal(data.download.files[0].name, 'sunset.jpg');++ assert.equal(data.pics.images[0].slug, 'img_a');++ assert.deepEqual(data.problems, []);++ assert.deepEqual(fakeMedia.calls[0], ['list', 'person-a']);++++ const broken = await serve({++ media: Object.assign({}, fakeMedia, {++ listFiles: async () => { throw Object.assign(new Error('nope'), { status: 502, code: 'media.upstream_error', detail: 'the drive is down' }); },++ }),++ });++ t.after(broken.close);++ const failed = await (await fetch(broken.base + '/api/v1/studio/files', { headers: { cookie: 'ov_token=person-a' } })).json();++ assert.equal(failed.problems[0].source, 'download');++ assert.equal(failed.problems[0].detail, 'the drive is down');++});++++test('the app catalogue, the attribution page and the small endpoints answer', async (t) => {++ const s = await serve(); t.after(s.close);++ const apps = await (await fetch(s.base + '/api/v1/studio/apps')).json();++ assert.deepEqual(apps.apps.map((one) => one.id), ['photo', 'light']);++ assert.equal(apps.apps[1].capabilities.save, true);++ assert.equal(apps.apps[0].capabilities.save, false);++ const attribution = await fetch(s.base + '/attribution');++ assert.equal(attribution.status, 200);++ const attributionHtml = await attribution.text();++ assert.match(attributionHtml, /storytold\/lightcraft/);++ assert.match(attributionHtml, /v0\.19\.0/);++ assert.equal((await fetch(s.base + '/api/health')).status, 200);++ assert.equal((await fetch(s.base + '/llms.txt')).status, 200);++ assert.equal((await fetch(s.base + '/robots.txt')).status, 200);++ assert.equal((await fetch(s.base + '/nope')).status, 404);++});
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -0,0 +1,112 @@
+name: bundles
+# The app bundles are built here, from pinned upstream tags, checked by the first-frame smoke test
+# and published as a release the host can fetch without a GitHub account. A bundle is never kept
+# in git.
+on:
+ push:
+ branches: [main]
+ pull_request:
+ paths:
+ - "scripts/build-bundles.sh"
+ - "scripts/smoke.js"
+ - "server/bundle-files.js"
+ - "server/bundles.js"
+ workflow_dispatch:
+permissions:
+ contents: read
+concurrency:
+ group: bundles-${{ github.ref }}
+ cancel-in-progress: true
+jobs:
+ build:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 120
+ 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: check out PhotoCraft
+ uses: actions/checkout@v4
+ with:
+ repository: storytold/photocraft
+ ref: v0.21.0
+ path: vendor/photocraft
+ - name: check out LightCraft
+ uses: actions/checkout@v4
+ with:
+ repository: storytold/lightcraft
+ ref: v0.19.0
+ path: vendor/lightcraft
+ - name: rust toolchain
+ uses: dtolnay/rust-toolchain@stable
+ with:
+ targets: wasm32-unknown-unknown
+ - name: trunk
+ run: |
+ curl -fsSL https://github.com/trunk-rs/trunk/releases/download/v0.21.14/trunk-x86_64-unknown-linux-gnu.tar.gz | tar -xz -C /usr/local/bin trunk
+ trunk --version
+ - name: wasm-bindgen
+ run: |
+ curl -fsSL https://github.com/rustwasm/wasm-bindgen/releases/download/0.2.129/wasm-bindgen-0.2.129-x86_64-unknown-linux-musl.tar.gz | tar -xz -C /tmp
+ sudo cp /tmp/wasm-bindgen-0.2.129-x86_64-unknown-linux-musl/wasm-bindgen* /usr/local/bin/
+ wasm-bindgen --version
+ - name: build the bundles
+ run: scripts/build-bundles.sh all
+ - name: smoke test PhotoCraft
+ run: node scripts/smoke.js --app photo --dir artifacts/photo
+ - name: smoke test LightCraft
+ run: node scripts/smoke.js --app light --dir artifacts/light
+ - name: checksums
+ run: |
+ cd artifacts
+ find . -type f -print0 | sort -z | xargs -0 sha256sum > bundles.sha256
+ cat bundles.sha256
+ - uses: actions/upload-artifact@v4
+ with:
+ name: studio-bundles
+ path: |
+ artifacts/photo
+ artifacts/light
+ artifacts/bundles.sha256
+ retention-days: 30
+
+ # On main the built bundles become a release the host fetches with curl: no GitHub account and
+ # no token are needed there. The assets of an existing release are replaced.
+ publish:
+ name: publish the bundles release
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
+ needs: build
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22.22.1
+ - uses: actions/download-artifact@v4
+ with:
+ name: studio-bundles
+ path: artifacts
+ - name: pack and publish
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -euo pipefail
+ PHOTO_TAG="$(node -e "process.stdout.write(require('./server/adapters/photo').base.tag)")"
+ LIGHT_TAG="$(node -e "process.stdout.write(require('./server/adapters/light').base.tag)")"
+ RELEASE="bundles-${PHOTO_TAG}-${LIGHT_TAG}"
+ TARBALL="studio-bundles-${PHOTO_TAG}-${LIGHT_TAG}.tar.gz"
+ cd artifacts
+ tar -czf "$TARBALL" photo light
+ sha256sum "$TARBALL" > SHA256SUMS
+ cat SHA256SUMS
+ if gh release view "$RELEASE" >/dev/null 2>&1; then
+ echo "replacing the assets of $RELEASE"
+ gh release upload "$RELEASE" "$TARBALL" SHA256SUMS --clobber
+ else
+ gh release create "$RELEASE" "$TARBALL" SHA256SUMS --title "$RELEASE" --notes "The app bundles for PhotoCraft ${PHOTO_TAG} and LightCraft ${LIGHT_TAG}. Check SHA256SUMS before unpacking; scripts/fetch-bundles.sh does that on the host."
+ fi
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+permissions:
+ contents: read
+jobs:
+ test:
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22.22.1
+ cache: npm
+ - run: npm ci --no-audit --no-fund
+ - name: syntax
+ run: find server test scripts public/js -name '*.js' -print0 | xargs -0 -n1 node --check
+ - run: npm test
+ security:
+ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+# Attribution
+
+OpenVibe.Studio hosts builds of open-source projects. Each build keeps its own licence and its
+own attribution files, and the build artifacts carry them.
+
+## PhotoCraft
+
+- Project: <https://github.com/storytold/photocraft>
+- Build: `v0.21.0` (the tag is pinned in `server/adapters/photo.js` and in `.github/workflows/bundle.yml`)
+- Licence: MIT OR Apache-2.0, at your choice. The build keeps `LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE` and `ATTRIBUTION.md`.
+- Built by: our CI, with Rust stable, the `wasm32-unknown-unknown` target and Trunk 0.21.14.
+- Why our own build: the release archive of the web build does not start (the loader looks for its
+ module under a path the archive does not have). The build in this repository is made from the
+ pinned tag and is checked by a smoke test that fails unless the app reaches its first frame.
+
+## LightCraft
+
+- Project: <https://github.com/storytold/lightcraft>
+- Build: `v0.19.0` (the tag is pinned in `server/adapters/light.js` and in `.github/workflows/bundle.yml`)
+- Licence: MIT OR Apache-2.0, at your choice. The build keeps `LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE` and `ATTRIBUTION.md`.
+- Built by: our CI, with Rust stable, the `wasm32-unknown-unknown` target, `cargo xtask web` and wasm-bindgen 0.2.129.
+- The optional Chinese font (16 MB) is left out of our build, so Chinese and Japanese text has no
+ glyphs. The two fonts the module always embeds are under the SIL Open Font License 1.1: BIZ
+ UDPGothic and Noto Sans Thai. Their licence texts travel with the bundle.
+
+## OpenVibe.Studio
+
+The service in this repository is MIT licensed (see `LICENSE`). It is a separate work: it hosts a
+build of each project under its own terms and adds the OpenVibe layer around it (the sign-in, the
+files a person owns, and the way back out).
+
+The OpenVibe Shared kit and the OpenVibe SDK and Contracts packages are used as dependencies;
+each keeps its own licence.
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 OpenVibers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+OpenVibe.Studio
+Copyright (c) 2026 OpenVibers
+
+This product includes software developed by the Storytold project:
+
+ PhotoCraft (https://github.com/storytold/photocraft), build v0.21.0
+ LightCraft (https://github.com/storytold/lightcraft), build v0.19.0
+
+Both are licensed under either of
+
+ the MIT License, or
+ the Apache License, Version 2.0
+
+at your option. The licence files of each build (LICENSE-MIT, LICENSE-APACHE,
+NOTICE and ATTRIBUTION.md) travel with the bundle and are listed in
+STATUS.json. The full attribution is in ATTRIBUTION.md.
+
+The LightCraft web module embeds two fonts under the SIL Open Font License
+1.1: BIZ UDPGothic (the interface face) and Noto Sans Thai (for Thai file
+names). Their licence texts travel with the bundle as OFL-biz-ud-pgothic.txt
+and OFL-noto-sans-thai.txt.
+
+The OpenVibe.Studio service itself (the code in this repository) is MIT
+licensed; see LICENSE.
@@ -1,3 +1,70 @@
@@ -1,3 +1,70 @@
# OpenVibe.Studio
# OpenVibe.Studio
-The creative apps on the OpenVibe network: Photo (PhotoCraft) and Light (LightCraft). The service lands in the next pull request.
+Photo and Light: two open-source creative apps served inside an OpenVibe account.
+
+- **Photo** is [PhotoCraft](https://github.com/storytold/photocraft), a layered photo editor.
+- **Light** is [LightCraft](https://github.com/storytold/lightcraft), a photo library and RAW developer.
+
+The apps are the work of their own authors and are used under their own licences (MIT OR
+Apache-2.0). We build them from pinned release tags, serve them under an OpenVibe sign-in, and
+keep the licence and attribution with every build. See [ATTRIBUTION.md](ATTRIBUTION.md) and
+[STATUS.json](STATUS.json).
+
+## What is in here
+
+ server/ the service: pages, the API, the adapters, the OpenVibe clients
+ public/js/ the browser side: one driver per app, and the layer around it
+ test/ the tests
+ scripts/ the bundle build and the first-frame smoke test
+ .github/workflows/ ci.yml (tests and the pinned security workflow) and bundle.yml (the builds)
+
+## Running it
+
+ npm ci
+ npm start # http://127.0.0.1:5155, the drill port is 15155
+
+ npm test # the tests
+ node scripts/smoke.js --app light --dir artifacts/light # the first-frame check
+
+The service serves the app bundles from `STUDIO_BUNDLE_ROOT` (by default `public/apps`). Without a
+bundle the app page says so instead of showing an empty frame.
+
+## The bundles
+
+A bundle is never kept in git. The `bundles` workflow checks out the pinned upstream tags, builds
+them (Rust stable, `wasm32-unknown-unknown`, Trunk 0.21.14 for PhotoCraft, `cargo xtask web` and
+wasm-bindgen 0.2.129 for LightCraft), runs the smoke test and publishes the result with a
+checksum file. The deploy fetches that artifact, checks the checksums and unpacks it into the
+bundle root.
+
+The smoke test is the gate: the build fails unless the app reaches its first frame in headless
+Chrome. A bundle that loads but never starts is worse than no bundle.
+
+PhotoCraft is built by us on purpose. The published web release archive does not start: its loader
+looks for the module under a path the archive does not have.
+
+## The layer around an app
+
+Every app sits in an iframe inside our own page, with the OpenVibe navbar outside it. One adapter
+per app keeps the same contract, so a base can be swapped:
+
+ open(file) open a file from the person's OpenVibe library
+ save() hand an export to the person's OpenVibe.Download drive
+ commands() the app's own commands
+
+- **LightCraft** speaks the documented host bridge (`lightcraft:open`, `lightcraft:cmd`,
+ `lightcraft:save`) to a same-origin parent, so files open and exports come back.
+- **PhotoCraft** has no scriptable API in the browser: a file is dropped on the app, and there is
+ no export the page can ask for. The app page says so.
+
+## Honest limits
+
+- Heavy first load: both apps are native editors compiled to WebAssembly.
+- A phone with 4 GB of RAM can run out of memory on big files: a 4 GB Photoshop file used 2.7-3.0 GB.
+- Online only: neither app caches for offline use.
+- Cut (FilmCraft) is not part of Studio v0.
+
+## Licence
+
+The service is MIT licensed ([LICENSE](LICENSE)). The apps keep their own licences, and the
+attribution is in [ATTRIBUTION.md](ATTRIBUTION.md).
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -0,0 +1,95 @@
+{
+ "repository": "OpenVibers/OpenVibe.Studio",
+ "stage": "beta",
+ "code": true,
+ "product": "Photo and Light, the creative apps on the OpenVibe network",
+ "runtime": "node 22, Express, the OpenVibe Shared shell and the openvibe-sdk SSO client; the apps themselves are Rust compiled to WebAssembly (Trunk for PhotoCraft, cargo xtask web for LightCraft) and served as static bundles",
+ "port": 5155,
+ "drill_port": 15155,
+ "domain": "openvibe.studio",
+ "contracts": "openvibe-contracts v0.346.0, openvibe-sdk v0.101.0, openvibe-shared v4.156.0",
+ "capabilities": [
+ "studio.apps.list",
+ "studio.files.list",
+ "studio.file.open",
+ "studio.file.save"
+ ],
+ "deployed": false,
+ "deployment": "Not deployed in this pull request. openvibe.studio stays parked until the owner launches it; the service listens on 127.0.0.1:5155 and the drill port is 15155. The bundles are built by the bundles workflow on every push to main and published as the release bundles-<photo tag>-<light tag> with a SHA256SUMS file; scripts/fetch-bundles.sh fetches that release on the host with curl (no GitHub account), checks the sums and swaps the tree in one step. No bundle is kept in git.",
+ "bases": [
+ {
+ "id": "photo",
+ "name": "PhotoCraft",
+ "repository": "https://github.com/storytold/photocraft",
+ "tag": "v0.21.0",
+ "licence": "MIT OR Apache-2.0",
+ "licence_files": [
+ "LICENSE-MIT",
+ "LICENSE-APACHE",
+ "NOTICE",
+ "ATTRIBUTION.md"
+ ],
+ "built_by": "our CI (Rust stable, wasm32-unknown-unknown, Trunk 0.21.14)",
+ "why": "the published web release archive does not start, so the build is made from the pinned tag and checked by a first-frame smoke test",
+ "adapter": "server/adapters/photo.js",
+ "capabilities": {
+ "open": true,
+ "save": false,
+ "commands": false
+ }
+ },
+ {
+ "id": "light",
+ "name": "LightCraft",
+ "repository": "https://github.com/storytold/lightcraft",
+ "tag": "v0.19.0",
+ "licence": "MIT OR Apache-2.0",
+ "licence_files": [
+ "LICENSE-MIT",
+ "LICENSE-APACHE",
+ "NOTICE",
+ "ATTRIBUTION.md",
+ "OFL-biz-ud-pgothic.txt",
+ "OFL-noto-sans-thai.txt"
+ ],
+ "built_by": "our CI (Rust stable, wasm32-unknown-unknown, cargo xtask web, wasm-bindgen 0.2.129)",
+ "why": "its web build works and speaks the documented host bridge, so files can be opened and exports handed over",
+ "adapter": "server/adapters/light.js",
+ "capabilities": {
+ "open": true,
+ "save": true,
+ "commands": true
+ }
+ }
+ ],
+ "excluded": [
+ {
+ "name": "Cut (FilmCraft)",
+ "reason": "desktop and agent only; not part of Studio v0"
+ }
+ ],
+ "features": [
+ "one service for both apps, modelled on OpenVibe.Computer: the Shared navbar, the sign-in, STATUS.json and a pinned security workflow",
+ "the apps are served at /photo and /light inside our own layer: the navbar sits outside the app frame",
+ "one adapter per app with the same contract (open(file), save(), commands()), so a base can be swapped",
+ "Open from OpenVibe: the files a person owns in OpenVibe.Download and the pictures in OpenVibe.Pics",
+ "Save to OpenVibe: an export goes to the person's OpenVibe.Download drive (start, parts, complete)",
+ "the headers the WebAssembly builds need: application/wasm, no-cache on the loader, immutable on the hashed parts, no COOP/COEP (the builds are not cross-origin isolated and do not need it)",
+ "a build smoke test that fails the build unless the app reaches its first frame in headless Chrome",
+ "the home states the measured limits, credits each upstream project and links its licence"
+ ],
+ "limits_measured": [
+ "LightCraft reaches its first frame in about 2.5 s at 1280 px and 0.6 s at 390 px; PhotoCraft in about 1.3 s once its loader path is right",
+ "a 4 GB Photoshop file passes PhotoCraft's cap but uses 2.7-3.0 GB of memory: a phone with 4 GB can run out of memory on big files",
+ "neither app works offline: there is no service worker",
+ "OpenVibe.Download has no route that hands a file's bytes to a person's token yet, so a file opens here only through a public share link"
+ ],
+ "tests": [
+ "the build smoke test (scripts/smoke.js, run by the bundles workflow)",
+ "the headers of the bundles (server/bundles.js)",
+ "the adapter contract with a fake app (test/adapters.test.js)",
+ "the sign-in gating of Save, the files listing and the page rendering (test/studio.test.js)",
+ "the OpenVibe.Download client (test/media.test.js)"
+ ],
+ "updated": "2026-11-08"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+# Launch sequence for openvibe.studio
+
+This pull request prepares the service, the builds and the host files. Opus performs the host-level
+swap after review; openvibe.studio stays parked until then, and no host change is part of this
+pull request.
+
+1. **DNS and Cloudflare:** make sure the `openvibe.studio` 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.
+2. **TLS:** issue `/etc/letsencrypt/live/openvibe.studio/{fullchain.pem,privkey.pem}` for apex and
+ `www` before enabling the TLS server block.
+3. **Network OAuth:** deploy the Network change with the Studio cutover. It seeds the confidential
+ OAuth client `studio`, the redirect URI `https://openvibe.studio/auth/callback`, and lists
+ Studio in the Network Frame.
+4. **Service:** run `sudo ovhost service add studio`. It clones this repository into
+ `/opt/openvibe.studio`, creates `/etc/openvibe/studio.env` with mode 0600 and prints the
+ remaining steps without printing secrets. Use the Network service-principal setup to create or
+ rotate the client `studio`; set `OV_OAUTH_CLIENT_ID=studio`. The unit is
+ `deploy/systemd/openvibe-studio.service`: it runs as `ubuntu` from `/opt/openvibe.studio` on
+ `127.0.0.1:5155` with the same hardening as the other room services. If the host tooling does
+ not install it, put it in place by hand:
+
+ sudo cp deploy/systemd/openvibe-studio.service /etc/systemd/system/
+ sudo systemctl daemon-reload
+ sudo systemctl enable --now openvibe-studio
+
+ Then check `http://127.0.0.1:5155/api/health`.
+5. **The bundles:** the `bundles` workflow builds PhotoCraft and LightCraft from their pinned
+ tags on every push to `main`, checks that each build reaches its first frame, and publishes the
+ two trees as one tarball plus a `SHA256SUMS` file in the release
+ `bundles-<photo tag>-<light tag>`. The release is public: the host fetches it with curl and
+ needs no GitHub account and no token.
+
+ sudo -u ubuntu STUDIO_BUNDLE_ROOT=/var/lib/openvibe-studio/apps \
+ /opt/openvibe.studio/scripts/fetch-bundles.sh
+
+ Run it **after each deploy** and **whenever the pinned tags change** (the release name follows
+ the tags, so a new tag means a new release). The script checks the tarball against
+ `SHA256SUMS` before it unpacks anything, and it swaps the bundle tree in one step: the root
+ becomes a link to `public/versions/<release>`, so a running service never sees half a bundle.
+ Restart the service afterwards so it picks the new tree up.
+6. **Vhost and parked redirect:** the reference vhost is `deploy/nginx/openvibe.studio.conf`. It
+ sends plain HTTP to HTTPS, `www` to the apex, rate-limits `/auth/` and `/api/` (zones
+ `studio_auth` and `studio_api`), caps request bodies at 64k (the apps run in the browser) and
+ serves the bundle files under `/apps/` from disk with the rules the WebAssembly build needs:
+ `application/wasm`, no-cache on the loader and the part list, and a year of immutable cache on
+ the hashed parts only. Its bundle root is `/opt/openvibe.studio/public`, so keep it in step with
+ `STUDIO_BUNDLE_ROOT` if that path differs. After review and the readiness checks, Opus removes
+ the `openvibe.studio` redirect blocks from Network's `parked-domains.conf`, installs the vhost
+ with `sudo ovhost nginx render studio --install`, runs `nginx -t` and reloads nginx as one
+ host-level swap.
+7. **Verify:** check `/`, `/photo`, `/light`, `/api/health` and `/auth/login` through HTTPS, and
+ that `/apps/light/lightcraft_web_bg.wasm` answers with `application/wasm`.
+
+Two things to keep in mind at launch. The 64k body limit in the vhost fits the pages and the app
+frames; a save that carries a large export through the service does not fit in it, so big exports
+have to be saved from the app itself. And the bundles are a moving target: a new pinned tag means a
+new release, and the fetch script has to run again before the service serves the new build.
+
+The drill port is the service port plus 10000: `5155` serves the site, `15155` is the drill.
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -0,0 +1,95 @@
+## openvibe.studio — reference vhost for ov-vhost-install.
+## Studio answers pages and /api/ on 127.0.0.1:5155. The app bundles under /apps/ are static
+## files on disk: the WebAssembly needs its own type, and only the hashed files may be cached hard.
+limit_req_zone $binary_remote_addr zone=studio_auth:10m rate=10r/m;
+limit_req_zone $binary_remote_addr zone=studio_api:10m rate=5r/s;
+
+# A bundle file whose name carries a content hash never changes, so it may be cached for a year.
+# Every other file keeps its name from release to release and has to be revalidated.
+map $uri $studio_cache {
+ default "no-cache";
+ "~^/apps/.+\.part-[0-9a-f]+$" "public, max-age=31536000, immutable";
+ "~^/apps/.+-[0-9a-f]{8,}\.[a-z0-9]+$" "public, max-age=31536000, immutable";
+}
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name openvibe.studio www.openvibe.studio;
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://openvibe.studio$request_uri; }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name www.openvibe.studio;
+ ssl_certificate /etc/letsencrypt/live/openvibe.studio/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.studio/privkey.pem;
+ return 301 https://openvibe.studio$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name openvibe.studio;
+ ssl_certificate /etc/letsencrypt/live/openvibe.studio/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.studio/privkey.pem;
+ access_log /var/log/nginx/openvibe.studio.access.log;
+ error_log /var/log/nginx/openvibe.studio.error.log;
+ # The apps run in the browser and a save goes to the OpenVibe APIs, so the body stays small.
+ client_max_body_size 64k;
+ 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;
+
+ # The app bundles: static files under the bundle root. Production keeps them outside the checkout
+ # (STUDIO_BUNDLE_ROOT=/var/lib/openvibe-studio/apps, filled by scripts/fetch-bundles.sh) so a deploy's
+ # git pull never touches them. The types are listed here because the module is only compiled by the
+ # browser when it is served as application/wasm.
+ location /apps/ {
+ root /var/lib/openvibe-studio;
+ types {
+ text/html html;
+ text/css css;
+ text/javascript js mjs;
+ application/wasm wasm;
+ application/json json;
+ text/plain txt;
+ text/xml xml;
+ font/otf otf;
+ font/woff woff;
+ font/woff2 woff2;
+ image/png png;
+ image/jpeg jpg jpeg;
+ image/svg+xml svg;
+ image/webp webp;
+ image/x-icon ico;
+ }
+ default_type application/octet-stream;
+ 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;
+ add_header Cache-Control $studio_cache always;
+ try_files $uri =404;
+ }
+ location = /auth/me { limit_req zone=studio_api burst=30 nodelay; limit_req_status 429; proxy_pass http://127.0.0.1:5155; }
+ location /auth/ { limit_req zone=studio_auth burst=10 nodelay; limit_req_status 429; proxy_pass http://127.0.0.1:5155; }
+ location /api/ { limit_req zone=studio_api burst=20 nodelay; limit_req_status 429; proxy_pass http://127.0.0.1:5155; }
+ location ~* ^/(metrics|internal)(/|$) { return 404; }
+ location ~ /\.(?!well-known) { deny all; }
+ location / { proxy_pass http://127.0.0.1:5155; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+[Unit]
+Description=OpenVibe.Studio — Photo and Light, the creative apps on the OpenVibe network.
+After=network.target
+Wants=openvibe-network.service
+
+[Service]
+Type=simple
+User=ubuntu
+Group=ubuntu
+WorkingDirectory=/opt/openvibe.studio
+EnvironmentFile=/etc/openvibe/studio.env
+Environment=NODE_ENV=production
+Environment=PORT=5155
+Environment=HOST=127.0.0.1
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/bin/env node /opt/openvibe.studio/server/index.js
+Restart=always
+RestartSec=5
+KillSignal=SIGTERM
+TimeoutStopSec=15
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+
+[Install]
+WantedBy=multi-user.target
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/home-1280-dark.png differ
Binary files /dev/null and b/docs/screenshots/home-1280-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/home-1280-light.png differ
Binary files /dev/null and b/docs/screenshots/home-1280-light.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/home-390-dark.png differ
Binary files /dev/null and b/docs/screenshots/home-390-dark.png differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/docs/screenshots/home-390-light.png differ
Binary files /dev/null and b/docs/screenshots/home-390-light.png differ
new file mode 100644
new file mode 100644
@@ -0,0 +1,1432 @@
@@ -0,0 +1,1432 @@
+{
+ "name": "openvibe-studio",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-studio",
+ "version": "0.1.0",
+ "license": "MIT",
+ "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.101.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.156.0"
+ },
+ "devDependencies": {
+ "playwright-core": "1.56.1"
+ },
+ "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.101.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.101.0",
+ "integrity": "sha512-W0WVhpc4cL0zJOW7IRAzz1polzR1MU571SMyDUEh9CeJWlocENlISbz9rr78AZmrcmqCvAn+25D3aLTNQpO7Ug==",
+ "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.156.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.156.0",
+ "integrity": "sha512-2GCk8sILS2FH3mKu7boRwc73TxMJjsaC1kgOf4j3qpHJnENlRC52HWrFg7neF/0m4XsZL3x2EvMC0FSM4bTsKw==",
+ "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/playwright-core": {
+ "version": "1.56.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
+ "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.16.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",
+ "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.6",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz",
+ "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/uhyphen": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
+ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
+ "license": "ISC"
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+{
+ "name": "openvibe-studio",
+ "version": "0.1.0",
+ "private": true,
+ "description": "OpenVibe.Studio: Photo and Light — the creative apps on the OpenVibe network",
+ "main": "server/index.js",
+ "scripts": {
+ "start": "node server/index.js",
+ "dev": "NODE_ENV=development node server/index.js",
+ "test": "node --test test/*.test.js",
+ "smoke": "node scripts/smoke.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.101.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.156.0"
+ },
+ "devDependencies": {
+ "playwright-core": "1.56.1"
+ },
+ "license": "MIT"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+: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; }
+.studio-page { box-sizing: border-box; max-width: 1180px; margin: 0 auto; padding: 18px 24px 80px; }
+.studio-page a { color: var(--accent); text-underline-offset: 3px; }
+.hero { padding: 56px 0 40px; max-width: 780px; }
+.hero h1, .page-intro h1, .app-head h1 { font-size: clamp(2.1rem, 4.6vw, 3.4rem); line-height: 1.05; letter-spacing: -.045em; margin: 12px 0 18px; }
+.hero p, .page-intro p, .app-head p { color: var(--muted); font-size: 1.06rem; line-height: 1.6; max-width: 68ch; }
+.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; font-size: .95rem;
+}
+.button-primary { background: var(--accent); color: white !important; }
+.button-secondary { background: var(--panel); border-color: var(--line); color: inherit; }
+.button-quiet { background: var(--panel); border-color: var(--line); min-height: 38px; padding: 0 14px; font-size: .9rem; color: inherit; }
+.button-primary:disabled, .button-secondary:disabled, .button-quiet:disabled { opacity: .55; cursor: not-allowed; }
+.section-heading { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; justify-content: space-between; margin: 34px 0 18px; }
+.section-heading h2 { font-size: 1.6rem; letter-spacing: -.02em; margin: 8px 0 0; }
+.section-heading p { color: var(--muted); margin: 0; max-width: 46ch; font-size: .95rem; }
+.app-grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 330px), 1fr)); }
+.app-card { background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 20px 22px 22px; }
+.app-card h3 { font-size: 1.22rem; line-height: 1.25; margin: 10px 0 10px; letter-spacing: -.015em; }
+.app-card p { color: var(--muted); line-height: 1.55; font-size: .97rem; }
+.app-limits { margin: 12px 0 16px; padding-left: 18px; color: var(--muted); font-size: .9rem; line-height: 1.5; }
+.app-limits li { margin-bottom: 6px; }
+.app-licence { font-size: .85rem; border-top: 1px dashed var(--line); padding-top: 12px; }
+.limit-list { margin: 0; padding-left: 20px; color: var(--muted); line-height: 1.6; max-width: 78ch; }
+.limit-list li { margin-bottom: 8px; }
+.fine-print { color: var(--muted); font-size: .86rem; line-height: 1.5; max-width: 74ch; }
+.app-head { display: grid; gap: 12px; margin: 30px 0 18px; }
+.app-head h1 { margin: 8px 0 6px; font-size: clamp(1.6rem, 3.4vw, 2.2rem); }
+.app-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
+.app-commands { display: flex; flex-wrap: wrap; gap: 8px; }
+.app-status { min-height: 22px; margin: 4px 0 0; font-size: .95rem; color: var(--muted); }
+.app-output { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; overflow: auto; max-height: 260px; font-size: .82rem; }
+.app-files { margin: 18px 0; padding: 16px 18px; border: 1px solid var(--line); border-radius: 14px; background: var(--panel); }
+.app-files h2 { font-size: 1.05rem; margin: 0 0 10px; }
+.app-files ul { list-style: none; margin: 0 0 8px; padding: 0; display: grid; gap: 8px; }
+.app-files li { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; justify-content: space-between; }
+.file-name { font-size: .95rem; overflow-wrap: anywhere; }
+.app-frame-wrap { margin-top: 18px; border: 1px solid var(--line); border-radius: 14px; overflow: hidden; background: #1c1c1c; }
+.app-frame { display: block; width: 100%; height: min(78vh, 900px); border: 0; }
+.app-missing { color: var(--muted); border: 1px dashed var(--line); border-radius: 14px; padding: 22px; }
+.page-intro { padding: 40px 0 10px; }
+@media (max-width: 520px) {
+ .studio-page { padding: 12px 16px 60px; }
+ .hero { padding: 34px 0 26px; }
+ .app-frame { height: 72vh; }
+}
+
+/* Theme tokens (Shared presets: a person's theme and light/dark follow), over the 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 short home (11-08): three features per app as quiet chips, one width buttons on a phone. */
+.app-features { list-style: none; display: flex; flex-wrap: wrap; gap: 6px; margin: 4px 0 16px; padding: 0; }
+.app-features li { padding: 4px 10px; border: 1px solid var(--line); border-radius: 999px; font-size: .85rem; color: var(--muted); }
+.app-card h3 { font-size: 1.5rem; margin: 0 0 6px; }
+#limits h2 { font-size: 1.25rem; margin: 0 0 10px; }
+#licences .fine-print a { color: inherit; }
+@media (max-width: 600px) { .hero .button-primary, .hero .button-secondary { display: flex; align-items: center; justify-content: center; min-height: 48px; padding-top: 0; padding-bottom: 0; margin: 0 0 10px; } }
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
+'use strict';
+/*
+ * The browser side of the adapters: one driver per app, all with the same contract
+ * (open(file), save(), run(command, params)). A driver only talks to the app inside the frame;
+ * everything around it - the navbar, the sign-in, the OpenVibe files - is the same for every app,
+ * so a base can be swapped without touching the page.
+ */
+(function (root, factory) {
+ const drivers = factory();
+ if (typeof module === 'object' && module.exports) module.exports = drivers;
+ else root.StudioDrivers = drivers;
+})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
+ function photocraft() {
+ return {
+ id: 'photocraft',
+ async open(ctx, file) {
+ const win = ctx.window;
+ if (!win || !win.document) {
+ return { ok: false, code: 'photo.frame.missing', detail: 'The PhotoCraft frame is not ready yet.' };
+ }
+ const bytes = file.bytes instanceof Uint8Array ? file.bytes : new Uint8Array(file.bytes || []);
+ const transfer = new win.DataTransfer();
+ transfer.items.add(new win.File([bytes], file.name || 'photo.jpg', { type: file.type || 'application/octet-stream' }));
+ const fire = (type) => win.document.dispatchEvent(new win.DragEvent(type, { bubbles: true, cancelable: true, dataTransfer: transfer }));
+ fire('dragenter');
+ fire('dragover');
+ fire('drop');
+ return { ok: true, detail: 'Dropped ' + (file.name || 'the file') + ' into PhotoCraft.' };
+ },
+ async save() {
+ return {
+ ok: false, code: 'photo.save.unsupported',
+ detail: "PhotoCraft's browser build has no scriptable export: export from the app and the file downloads to your browser.",
+ };
+ },
+ async run() {
+ return { ok: false, code: 'photo.commands.unsupported', detail: "PhotoCraft's browser build has no scriptable commands." };
+ },
+ };
+ }
+
+ function lightcraft() {
+ return {
+ id: 'lightcraft',
+ async open(ctx, file) {
+ const bytes = file.bytes instanceof Uint8Array ? file.bytes : new Uint8Array(file.bytes || []);
+ ctx.window.postMessage({ type: 'lightcraft:open', name: file.name || 'photo.jpg', bytes: bytes }, ctx.origin);
+ return { ok: true, detail: 'Sent ' + (file.name || 'the file') + ' to LightCraft.' };
+ },
+ async save(ctx) {
+ ctx.window.postMessage({ type: 'lightcraft:cmd', id: 'app.export', params: '{}' }, ctx.origin);
+ const handed = await ctx.nextSave(20000);
+ if (!handed) {
+ return { ok: false, code: 'light.save.timeout', detail: 'LightCraft did not hand over a file within 20 seconds.' };
+ }
+ if (handed.ok === false) {
+ return { ok: false, code: 'light.export.failed', detail: String(handed.detail || 'LightCraft could not export that file.') };
+ }
+ return { ok: true, detail: 'LightCraft handed over ' + handed.file.name + '.', file: handed.file };
+ },
+ async run(ctx, commandId, params) {
+ ctx.window.postMessage({ type: 'lightcraft:cmd', id: commandId, params: JSON.stringify(params || {}) }, ctx.origin);
+ const value = await ctx.nextResponse(20000);
+ if (!value) {
+ return { ok: false, code: 'light.command.timeout', detail: 'LightCraft did not answer ' + commandId + ' within 20 seconds.' };
+ }
+ if (value.ok === false) {
+ return { ok: false, code: 'light.command.failed', detail: String(value.value || 'the command failed') };
+ }
+ return { ok: true, detail: commandId + ' answered.', value: value.value };
+ },
+ };
+ }
+
+ return { photocraft: photocraft, lightcraft: lightcraft };
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,232 @@
@@ -0,0 +1,232 @@
+'use strict';
+/*
+ * The layer around an app: the OpenVibe files a person owns, the way into the app, the way back
+ * out, and the app's own commands. It talks to the app only through the driver, so the app can
+ * be swapped without changing this file.
+ */
+(function () {
+ const root = document.querySelector('[data-studio-app]');
+ if (!root) return;
+ const appId = root.getAttribute('data-studio-app');
+ const driverName = root.getAttribute('data-studio-driver');
+ const signedIn = root.getAttribute('data-studio-signed-in') === 'true';
+ const drivers = window.StudioDrivers || {};
+ const factory = drivers[driverName];
+ if (!factory) return;
+ const driver = factory();
+ const statusEl = root.querySelector('[data-role="status"]');
+ const outputEl = root.querySelector('[data-role="output"]');
+ const filesEl = root.querySelector('[data-role="files"]');
+ const fileListEl = root.querySelector('[data-role="file-list"]');
+ const filesNoteEl = root.querySelector('[data-role="files-note"]');
+ const iframe = root.querySelector('iframe');
+
+ let pendingSave = null;
+ let pendingResponse = null;
+
+ function say(text) {
+ if (statusEl) statusEl.textContent = text || '';
+ }
+
+ function show(value) {
+ if (!outputEl) return;
+ if (!value) {
+ outputEl.hidden = true;
+ outputEl.textContent = '';
+ return;
+ }
+ outputEl.hidden = false;
+ outputEl.textContent = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
+ }
+
+ function nextSave(timeout) {
+ return new Promise((resolve) => {
+ pendingSave = resolve;
+ window.setTimeout(() => {
+ if (pendingSave === resolve) {
+ pendingSave = null;
+ resolve(null);
+ }
+ }, timeout || 20000);
+ });
+ }
+
+ function nextResponse(timeout) {
+ return new Promise((resolve) => {
+ pendingResponse = resolve;
+ window.setTimeout(() => {
+ if (pendingResponse === resolve) {
+ pendingResponse = null;
+ resolve(null);
+ }
+ }, timeout || 20000);
+ });
+ }
+
+ const ctx = {
+ origin: window.location.origin,
+ iframe: iframe,
+ window: iframe ? iframe.contentWindow : null,
+ nextSave: nextSave,
+ nextResponse: nextResponse,
+ };
+
+ window.addEventListener('message', (event) => {
+ if (!iframe || event.source !== iframe.contentWindow) return;
+ const data = event.data || {};
+ if (data.type === 'lightcraft:save') {
+ const file = { name: data.name || 'export', bytes: data.bytes instanceof ArrayBuffer ? new Uint8Array(data.bytes) : new Uint8Array(data.bytes || []) };
+ if (pendingSave) {
+ const resolve = pendingSave;
+ pendingSave = null;
+ resolve({ ok: true, file: file });
+ }
+ return;
+ }
+ if (data.type === 'lightcraft:response') {
+ const answer = { ok: data.ok !== false, value: data.value };
+ if (pendingSave) {
+ const resolve = pendingSave;
+ pendingSave = null;
+ resolve({ ok: answer.ok, detail: answer.value });
+ return;
+ }
+ if (pendingResponse) {
+ const resolve = pendingResponse;
+ pendingResponse = null;
+ resolve(answer);
+ }
+ return;
+ }
+ if (data.type === 'lightcraft:failed') {
+ say('The app could not start: ' + (data.error || 'no reason given'));
+ }
+ });
+
+ function addFileItem(list, label, note, onOpen) {
+ const item = document.createElement('li');
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'button-quiet';
+ button.textContent = 'Open';
+ button.addEventListener('click', onOpen);
+ const name = document.createElement('span');
+ name.className = 'file-name';
+ name.textContent = label;
+ item.appendChild(name);
+ if (note) {
+ const meta = document.createElement('small');
+ meta.textContent = note;
+ item.appendChild(meta);
+ }
+ item.appendChild(button);
+ list.appendChild(item);
+ }
+
+ async function openBytes(source, id, name) {
+ say('Opening ' + name + '…');
+ const res = await fetch('/api/v1/studio/files/' + encodeURIComponent(source) + '/' + encodeURIComponent(id) + '/bytes', { credentials: 'same-origin' });
+ if (!res.ok) {
+ const problem = await res.json().catch(() => ({}));
+ say(problem.detail || problem.title || 'That file could not be opened.');
+ return;
+ }
+ const bytes = new Uint8Array(await res.arrayBuffer());
+ const result = await driver.open(ctx, { name: name, type: res.headers.get('content-type') || 'application/octet-stream', bytes: bytes });
+ say(result.detail || (result.ok ? 'Opened.' : 'The app did not take the file.'));
+ }
+
+ async function loadFiles() {
+ if (filesEl) filesEl.hidden = false;
+ if (fileListEl) fileListEl.textContent = '';
+ say('Looking up your OpenVibe files…');
+ const res = await fetch('/api/v1/studio/files', { credentials: 'same-origin' });
+ if (res.status === 401) {
+ say('Sign in to open files from OpenVibe.');
+ if (filesNoteEl) {
+ filesNoteEl.textContent = '';
+ const link = document.createElement('a');
+ link.href = '/auth/login?next=' + encodeURIComponent(window.location.pathname);
+ link.textContent = 'Sign in with OpenVibe';
+ filesNoteEl.appendChild(link);
+ }
+ return;
+ }
+ if (!res.ok) {
+ say('Your OpenVibe files could not be read just now.');
+ return;
+ }
+ const data = await res.json();
+ const problems = Array.isArray(data.problems) ? data.problems : [];
+ if (fileListEl) {
+ for (const file of (data.download && data.download.files) || []) {
+ addFileItem(fileListEl, file.name || file.id, file.content_type || '', () => openBytes('download', file.id, file.name || file.id));
+ }
+ for (const image of (data.pics && data.pics.images) || []) {
+ addFileItem(fileListEl, image.name || image.slug || image.id, 'OpenVibe.Pics', () => openBytes('pics', image.slug || image.id, image.name || image.slug || image.id));
+ }
+ if (!fileListEl.childNodes.length) {
+ const empty = document.createElement('li');
+ empty.textContent = 'No files found in your OpenVibe drive.';
+ fileListEl.appendChild(empty);
+ }
+ }
+ if (filesNoteEl) {
+ filesNoteEl.textContent = problems.length
+ ? problems.map((one) => one.source + ': ' + (one.detail || one.code)).join(' · ')
+ : 'Files come from OpenVibe.Download and OpenVibe.Pics.';
+ }
+ say('Your OpenVibe files are listed below.');
+ }
+
+ async function saveToOpenVibe() {
+ if (!signedIn) {
+ say('Sign in to save to OpenVibe.');
+ return;
+ }
+ say('Asking the app for the file…');
+ const result = await driver.save(ctx);
+ if (!result.ok || !result.file) {
+ say(result.detail || 'There was nothing to save.');
+ return;
+ }
+ const res = await fetch('/api/v1/studio/save', {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: { 'content-type': result.file.contentType || 'application/octet-stream', 'x-file-name': result.file.name || 'export' },
+ body: result.file.bytes,
+ });
+ if (!res.ok) {
+ const problem = await res.json().catch(() => ({}));
+ say(problem.detail || problem.title || 'The file could not be saved to OpenVibe.');
+ return;
+ }
+ say('Saved ' + (result.file.name || 'the file') + ' to your OpenVibe drive.');
+ }
+
+ root.addEventListener('click', async (event) => {
+ const button = event.target.closest('button');
+ if (!button) return;
+ const action = button.getAttribute('data-action');
+ if (action === 'open') {
+ event.preventDefault();
+ await loadFiles();
+ return;
+ }
+ if (action === 'save') {
+ event.preventDefault();
+ await saveToOpenVibe();
+ return;
+ }
+ const command = button.getAttribute('data-command');
+ if (command) {
+ event.preventDefault();
+ say('Running ' + command + '…');
+ const result = await driver.run(ctx, command, {});
+ say(result.detail || 'Done.');
+ show(result.ok ? result.value : null);
+ }
+ });
+
+ say(appId + ' is ready.');
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
+{
+ "openapi": "3.1.0",
+ "info": {
+ "title": "OpenVibe.Studio",
+ "version": "0.1.0",
+ "description": "Photo and Light: two open-source creative apps built from pinned release tags and served inside an OpenVibe account."
+ },
+ "servers": [
+ {
+ "url": "https://openvibe.studio"
+ }
+ ],
+ "paths": {
+ "/api/v1/studio/apps": {
+ "get": {
+ "summary": "The apps Studio serves, their capabilities and their commands.",
+ "responses": {
+ "200": {
+ "description": "The catalogue."
+ }
+ }
+ }
+ },
+ "/api/v1/studio/files": {
+ "get": {
+ "summary": "The files and pictures the signed-in person owns.",
+ "responses": {
+ "200": {
+ "description": "Files and pictures, with any source that failed."
+ },
+ "401": {
+ "description": "Sign in required."
+ }
+ }
+ }
+ },
+ "/api/v1/studio/files/{source}/{id}/bytes": {
+ "get": {
+ "summary": "The bytes of one file or picture.",
+ "parameters": [
+ {
+ "name": "source",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "download",
+ "pics"
+ ]
+ }
+ },
+ {
+ "name": "id",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The file."
+ },
+ "401": {
+ "description": "Sign in required."
+ },
+ "501": {
+ "description": "The host has no byte route for a person's token."
+ }
+ }
+ }
+ },
+ "/api/v1/studio/save": {
+ "post": {
+ "summary": "Save a file to the person's OpenVibe.Download drive.",
+ "responses": {
+ "201": {
+ "description": "Saved."
+ },
+ "401": {
+ "description": "Sign in required."
+ }
+ }
+ }
+ }
+ }
+}
new file mode 100755
new file mode 100755
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+#!/usr/bin/env bash
+# Builds the app bundles from their pinned upstream tags into artifacts/.
+#
+# The bundles are never kept in git: the deploy fetches them from the build artifacts by tag and
+# checksum. Each build keeps the upstream licence and attribution files with the bundle.
+#
+# Usage: scripts/build-bundles.sh [photocraft|lightcraft|all]
+set -euo pipefail
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+VENDOR="$ROOT/vendor"
+ARTIFACTS="$ROOT/artifacts"
+PHOTOCRAFT_TAG="v0.21.0"
+LIGHTCRAFT_TAG="v0.19.0"
+CRAFT_FONTS_REF="b076417b5880bd4154bd22f00695b1d28c6bea83"
+WHICH="${1:-all}"
+
+keep_docs() {
+ local from="$1" to="$2" f
+ for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md COPYRIGHT; do
+ [ -f "$from/$f" ] && cp "$from/$f" "$to/$f" || true
+ done
+}
+
+build_photocraft() {
+ echo "== PhotoCraft $PHOTOCRAFT_TAG"
+ cd "$VENDOR/photocraft"
+ PHOTOCRAFT_BUILD_SHA="$(git rev-parse HEAD)" ./packaging/web/package.sh
+ rm -rf "$ARTIFACTS/photo"
+ mkdir -p "$ARTIFACTS/photo"
+ rm -rf "$VENDOR/photocraft/unpacked"
+ mkdir -p "$VENDOR/photocraft/unpacked"
+ unzip -q dist/release/photocraft-web-*.zip -d "$VENDOR/photocraft/unpacked"
+ cp -R "$VENDOR/photocraft/unpacked"/photocraft-web-*/. "$ARTIFACTS/photo/"
+ # Upstream's web package puts snippets/ at the root while app/photocraft-web.js imports ./snippets/… from app/, so the
+ # app never starts (404s; upstream issues #10017/#10248). Measured fix from the Studio due diligence: a copy in app/.
+ if [ -d "$ARTIFACTS/photo/snippets" ] && [ -d "$ARTIFACTS/photo/app" ] && [ ! -d "$ARTIFACTS/photo/app/snippets" ]; then
+ cp -R "$ARTIFACTS/photo/snippets" "$ARTIFACTS/photo/app/snippets"
+ fi
+ keep_docs "$VENDOR/photocraft" "$ARTIFACTS/photo"
+}
+
+build_lightcraft() {
+ echo "== LightCraft $LIGHTCRAFT_TAG"
+ cd "$VENDOR/lightcraft"
+ cargo xtask web
+ rm -rf "$ARTIFACTS/light"
+ mkdir -p "$ARTIFACTS/light"
+ cp -R target/web-site/. "$ARTIFACTS/light/"
+ keep_docs "$VENDOR/lightcraft" "$ARTIFACTS/light"
+ # The module embeds two OFL fonts (the UI face and the Thai face); their licences travel with it.
+ for family in biz-ud-pgothic noto-sans-thai; do
+ url="https://raw.githubusercontent.com/storytold/craft-fonts/$CRAFT_FONTS_REF/fonts/$family/OFL.txt"
+ if curl -fsSL "$url" -o "$ARTIFACTS/light/OFL-$family.txt"; then
+ echo " licence: OFL-$family.txt"
+ else
+ rm -f "$ARTIFACTS/light/OFL-$family.txt"
+ echo " warning: could not fetch the licence of $family" >&2
+ fi
+ done
+}
+
+case "$WHICH" in
+ photocraft) build_photocraft ;;
+ lightcraft) build_lightcraft ;;
+ all) build_photocraft; build_lightcraft ;;
+ *) echo "usage: $0 [photocraft|lightcraft|all]" >&2; exit 2 ;;
+esac
+
+mkdir -p "$ARTIFACTS"
+( cd "$ARTIFACTS" && find . -type f -print0 | sort -z | xargs -0 sha256sum > "$ARTIFACTS/bundles.sha256" )
+echo "wrote $ARTIFACTS/bundles.sha256"
new file mode 100755
new file mode 100755
@@ -0,0 +1,52 @@
@@ -0,0 +1,52 @@
+#!/usr/bin/env bash
+# Fetches the built app bundles from the GitHub release and unpacks them into the bundle root.
+#
+# The release is public: the host needs no GitHub account and no token, only curl.
+# The tarball is checked against SHA256SUMS before anything is unpacked, and the unpack is atomic:
+# the new tree is built next to the old one and the link is swapped in one step.
+#
+# Usage: scripts/fetch-bundles.sh [release-name]
+set -euo pipefail
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+REPO="${OV_STUDIO_REPO:-OpenVibers/OpenVibe.Studio}"
+BUNDLE_ROOT="${STUDIO_BUNDLE_ROOT:-$ROOT/public/apps}"
+PHOTO_TAG="${PHOTO_TAG:-$(node -e "process.stdout.write(require('$ROOT/server/adapters/photo').base.tag)")}"
+LIGHT_TAG="${LIGHT_TAG:-$(node -e "process.stdout.write(require('$ROOT/server/adapters/light').base.tag)")}"
+RELEASE="${1:-bundles-$PHOTO_TAG-$LIGHT_TAG}"
+TARBALL="studio-bundles-$PHOTO_TAG-$LIGHT_TAG.tar.gz"
+BASE="${OV_STUDIO_RELEASE_BASE:-https://github.com/$REPO/releases/download/$RELEASE}"
+
+command -v curl >/dev/null || { echo "error: curl is required" >&2; exit 1; }
+command -v sha256sum >/dev/null || { echo "error: sha256sum is required" >&2; exit 1; }
+
+WORK="$(mktemp -d "${TMPDIR:-/tmp}/studio-bundles.XXXXXX")"
+cleanup() { rm -rf "$WORK"; }
+trap cleanup EXIT
+
+echo "fetching $RELEASE from $REPO"
+curl -fsSL -o "$WORK/$TARBALL" "$BASE/$TARBALL"
+curl -fsSL -o "$WORK/SHA256SUMS" "$BASE/SHA256SUMS"
+( cd "$WORK" && sha256sum -c SHA256SUMS )
+
+rm -rf "$WORK/unpacked"
+mkdir -p "$WORK/unpacked"
+tar -xzf "$WORK/$TARBALL" -C "$WORK/unpacked"
+for app in photo light; do
+ [ -f "$WORK/unpacked/$app/index.html" ] || { echo "error: the release has no $app/index.html" >&2; exit 1; }
+done
+
+# The bundle root is a link to a versioned tree, so the swap is one step and never half done.
+PARENT="$(dirname "$BUNDLE_ROOT")"
+NAME="$(basename "$BUNDLE_ROOT")"
+mkdir -p "$PARENT/versions"
+TARGET="$PARENT/versions/$RELEASE"
+rm -rf "$TARGET"
+mv "$WORK/unpacked" "$TARGET"
+if [ -e "$BUNDLE_ROOT" ] && [ ! -L "$BUNDLE_ROOT" ]; then
+ mv "$BUNDLE_ROOT" "$PARENT/versions/previous-$(date +%s)"
+fi
+ln -sfn "$TARGET" "$PARENT/.$NAME.tmp"
+mv -T "$PARENT/.$NAME.tmp" "$BUNDLE_ROOT"
+find "$PARENT/versions" -maxdepth 1 -mindepth 1 -type d ! -name "$RELEASE" -mtime +14 -exec rm -rf {} + 2>/dev/null || true
+
+echo "the bundles of $RELEASE are in place at $BUNDLE_ROOT"
new file mode 100644
new file mode 100644
@@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
+'use strict';
+/*
+ * The build smoke test: the bundle must reach its first frame in a real browser, or the build
+ * fails. A bundle that loads but never starts is worse than no bundle at all, so the check waits
+ * for the app's own "it is running" signal and fails the build when it never comes.
+ *
+ * The bundle is served by the service itself, so the check runs against the headers a person
+ * really gets: the WebAssembly type, the cache rules and the content security policy.
+ *
+ * Usage: node scripts/smoke.js --app photo --dir artifacts/photo [--timeout 300]
+ */
+const http = require('node:http');
+const fs = require('node:fs');
+const path = require('node:path');
+const { createApp } = require('../server/app');
+const { load: loadConfig } = require('../server/config');
+
+const args = process.argv.slice(2);
+function argOf(name, fallback) {
+ const index = args.indexOf(name);
+ return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
+}
+
+const app = argOf('--app', 'photo');
+const dir = path.resolve(argOf('--dir', path.join('artifacts', app)));
+const timeout = Number(argOf('--timeout', '300')) * 1000;
+
+const SIGNALS = {
+ photo: { loading: '#photocraft_loading', canvas: '#photocraft_canvas', failure: /PhotoCraft failed to start|PhotoCraft stopped/i },
+ light: { loading: '#lightcraft_loading', canvas: '#lightcraft_canvas', failure: /LightCraft failed to load/i },
+};
+
+async function main() {
+ const signal = SIGNALS[app];
+ if (!signal) {
+ console.error('smoke: unknown app ' + app);
+ process.exit(2);
+ }
+ if (!fs.existsSync(path.join(dir, 'index.html'))) {
+ console.error('smoke: no index.html in ' + dir);
+ process.exit(2);
+ }
+ let chromium;
+ try {
+ ({ chromium } = require('playwright-core'));
+ } catch {
+ console.error('smoke: playwright-core is not installed');
+ process.exit(2);
+ }
+ const root = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'studio-smoke-'));
+ fs.symlinkSync(dir, path.join(root, app), 'dir');
+ const config = Object.assign(loadConfig({}), { bundleRoot: root });
+ const { app: service } = createApp({ config });
+ const server = http.createServer(service);
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ const port = server.address().port;
+ const url = 'http://127.0.0.1:' + port + '/apps/' + app + '/index.html';
+ const launchArgs = ['--no-sandbox', '--disable-dev-shm-usage', '--enable-unsafe-swiftshader', '--use-angle=swiftshader'];
+ const candidates = [process.env.CHROME_PATH, '/opt/google/chrome/chrome', '/usr/bin/google-chrome', '/usr/bin/chromium'].filter(Boolean);
+ let browser;
+ const errors = [];
+ try {
+ let lastError = null;
+ for (const executablePath of candidates) {
+ if (!fs.existsSync(executablePath)) continue;
+ try {
+ browser = await chromium.launch({ executablePath: executablePath, args: launchArgs });
+ break;
+ } catch (error) {
+ lastError = error;
+ }
+ }
+ if (!browser) {
+ try {
+ browser = await chromium.launch({ channel: 'chrome', args: launchArgs });
+ } catch (error) {
+ lastError = error;
+ }
+ }
+ if (!browser) throw lastError || new Error('no Chrome to drive');
+ const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
+ page.on('console', (message) => {
+ if (message.type() === 'error') errors.push(message.text());
+ });
+ page.on('pageerror', (error) => errors.push(String(error)));
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: timeout });
+ const started = await page.waitForFunction(
+ (args) => {
+ const loading = document.querySelector(args.loading);
+ const text = document.body ? document.body.innerText : '';
+ if (new RegExp(args.failure).test(text)) return true;
+ return !loading;
+ },
+ { loading: signal.loading, failure: signal.failure.source },
+ { timeout: timeout },
+ ).then(() => true).catch(() => false);
+ const body = await page.evaluate(() => (document.body ? document.body.innerText : ''));
+ const canvas = await page.evaluate((selector) => {
+ const node = document.querySelector(selector);
+ if (!node) return null;
+ const box = node.getBoundingClientRect();
+ return { width: Math.round(box.width), height: Math.round(box.height) };
+ }, signal.canvas);
+ if (!started) {
+ console.error('smoke: ' + app + ' did not reach its first frame within ' + (timeout / 1000) + ' s');
+ console.error('smoke: page text: ' + body.replace(/\s+/g, ' ').slice(0, 400));
+ for (const error of errors.slice(0, 10)) console.error('smoke: console: ' + error);
+ process.exitCode = 1;
+ return;
+ }
+ if (signal.failure.test(body)) {
+ console.error('smoke: ' + app + ' reported a failure: ' + body.replace(/\s+/g, ' ').slice(0, 400));
+ process.exitCode = 1;
+ return;
+ }
+ if (!canvas || !canvas.width || !canvas.height) {
+ console.error('smoke: ' + app + ' has no canvas of a real size: ' + JSON.stringify(canvas));
+ process.exitCode = 1;
+ return;
+ }
+ console.log('smoke: ' + app + ' reached its first frame (canvas ' + canvas.width + 'x' + canvas.height + ')');
+ if (errors.length) {
+ console.log('smoke: console errors (not fatal):');
+ for (const error of errors.slice(0, 10)) console.log('smoke: ' + error);
+ }
+ } finally {
+ if (browser) await browser.close();
+ await new Promise((resolve) => server.close(resolve));
+ }
+}
+
+main().catch((error) => {
+ console.error('smoke: ' + (error && error.message ? error.message : String(error)));
+ process.exit(1);
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+'use strict';
+
+const ADAPTER_FIELDS = ['id', 'title', 'base', 'bundle', 'driver', 'capabilities', 'commands'];
+
+/**
+ * Every adapter (one per app) must have the same shape, so a base can be swapped for another
+ * without touching the layer around it.
+ */
+function assertAdapter(adapter) {
+ if (!adapter || typeof adapter !== 'object') throw new Error('an adapter must be an object');
+ for (const field of ADAPTER_FIELDS) {
+ if (!(field in adapter)) throw new Error(`adapter ${adapter.id || '?'} is missing ${field}`);
+ }
+ if (typeof adapter.id !== 'string' || !adapter.id) throw new Error('an adapter needs a string id');
+ if (typeof adapter.title !== 'string' || !adapter.title) throw new Error(`adapter ${adapter.id} needs a title`);
+ const base = adapter.base;
+ if (!base || typeof base.repository !== 'string' || typeof base.tag !== 'string' || typeof base.licence !== 'string') {
+ throw new Error(`adapter ${adapter.id} needs base.repository, base.tag and base.licence`);
+ }
+ if (!Array.isArray(base.licenceFiles) || !base.licenceFiles.length) throw new Error(`adapter ${adapter.id} needs base.licenceFiles`);
+ if (!adapter.bundle || typeof adapter.bundle.path !== 'string' || typeof adapter.bundle.entry !== 'string') {
+ throw new Error(`adapter ${adapter.id} needs bundle.path and bundle.entry`);
+ }
+ if (typeof adapter.driver !== 'string' || !adapter.driver) throw new Error(`adapter ${adapter.id} needs a driver name`);
+ for (const key of ['open', 'save', 'commands']) {
+ if (typeof (adapter.capabilities || {})[key] !== 'boolean') throw new Error(`adapter ${adapter.id} needs capability ${key}`);
+ }
+ if (!Array.isArray(adapter.commands)) throw new Error(`adapter ${adapter.id} needs a commands array`);
+ for (const command of adapter.commands) {
+ if (!command || typeof command.id !== 'string' || !command.id || typeof command.title !== 'string' || !command.title) {
+ throw new Error(`adapter ${adapter.id} has a command without an id and a title`);
+ }
+ }
+ return adapter;
+}
+
+/** The shape the browser side of an adapter (the driver) must have. */
+function assertDriver(driver) {
+ if (!driver || typeof driver !== 'object') throw new Error('a driver must be an object');
+ for (const method of ['open', 'save', 'run']) {
+ if (typeof driver[method] !== 'function') throw new Error(`driver ${driver.id || '?'} is missing ${method}()`);
+ }
+ return driver;
+}
+
+module.exports = { assertAdapter, assertDriver, ADAPTER_FIELDS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+'use strict';
+const { assertAdapter, assertDriver } = require('./contract');
+const photo = require('./photo');
+const light = require('./light');
+
+const ADAPTERS = [photo, light];
+
+function byId(id) {
+ return ADAPTERS.find((adapter) => adapter.id === id) || null;
+}
+
+module.exports = { ADAPTERS, byId, assertAdapter, assertDriver };
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+'use strict';
+const { assertAdapter } = require('./contract');
+
+/**
+ * LightCraft. The web build speaks the documented host bridge (lightcraft:open, lightcraft:cmd,
+ * lightcraft:save) to a same-origin parent page, so files can be opened and exports can be handed
+ * over to OpenVibe.
+ */
+const adapter = {
+ id: 'light',
+ title: 'LightCraft',
+ base: {
+ name: 'LightCraft',
+ repository: 'https://github.com/storytold/lightcraft',
+ tag: 'v0.19.0',
+ licence: 'MIT OR Apache-2.0',
+ licenceFiles: ['LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE', 'ATTRIBUTION.md'],
+ upstream: 'https://github.com/storytold/lightcraft',
+ builtBy: 'our CI (Rust stable, wasm32-unknown-unknown, cargo xtask web, wasm-bindgen 0.2.129)',
+ },
+ bundle: { path: 'light', entry: 'index.html', query: '?host=parent' },
+ driver: 'lightcraft',
+ capabilities: { open: true, save: true, commands: true },
+ commands: [
+ { id: 'library.info', title: 'Library info', description: 'What the app knows about your library: photo count, size and where it is kept.' },
+ { id: 'library.state', title: 'Library state', description: 'The source, filter, sort and selection in the app right now.' },
+ { id: 'photo.inspect', title: 'Active photo', description: 'What the app knows about the photo in view.' },
+ { id: 'develop.get', title: 'Develop settings', description: 'The develop settings of the photo in view.' },
+ { id: 'develop.controls', title: 'Develop controls', description: 'Every develop control with its current value.' },
+ ],
+};
+
+module.exports = assertAdapter(adapter);
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+'use strict';
+const { assertAdapter } = require('./contract');
+
+/**
+ * PhotoCraft. The web build has no scriptable API of its own: files are opened by dropping them
+ * on the app, and there is no export the page can ask for. Both limits are shown in the app, not
+ * hidden.
+ */
+const adapter = {
+ id: 'photo',
+ title: 'PhotoCraft',
+ base: {
+ name: 'PhotoCraft',
+ repository: 'https://github.com/storytold/photocraft',
+ tag: 'v0.21.0',
+ licence: 'MIT OR Apache-2.0',
+ licenceFiles: ['LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE', 'ATTRIBUTION.md'],
+ upstream: 'https://github.com/storytold/photocraft',
+ builtBy: 'our CI (Rust stable, wasm32-unknown-unknown, Trunk 0.21.14)',
+ },
+ bundle: { path: 'photo', entry: 'index.html', query: '' },
+ driver: 'photocraft',
+ capabilities: { open: true, save: false, commands: false },
+ commands: [],
+};
+
+module.exports = assertAdapter(adapter);
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+const express = require('express');
+const { ADAPTERS } = require('./adapters');
+
+/** The Studio API: what the apps are, what a person owns, and the way in and out. */
+function createApi({ sso, media, pics }) {
+ const r = express.Router();
+
+ 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: 'studio.auth.required',
+ detail: 'Sign in with OpenVibe to open and save files.',
+ });
+ 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 || 'OpenVibe request failed', status,
+ code: e.code || 'studio.upstream_error', detail: e.detail || '',
+ });
+ });
+
+ r.get('/apps', (_req, res) => res.json({
+ apps: ADAPTERS.map((adapter) => ({
+ id: adapter.id, title: adapter.title, capabilities: adapter.capabilities,
+ commands: adapter.commands, base: adapter.base,
+ })),
+ }));
+
+ r.get('/files', wrap(async (req, res) => {
+ const value = token(req, res);
+ if (!value) return;
+ const out = { download: { files: [], folders: [], usage: null }, pics: { images: [] }, problems: [] };
+ try {
+ const data = await media.listFiles(value, { limit: req.query.limit });
+ out.download = data;
+ } catch (e) {
+ out.problems.push({ source: 'download', code: e.code || 'media.upstream_error', detail: e.detail || e.message });
+ }
+ try {
+ out.pics.images = await pics.listImages(value);
+ } catch (e) {
+ out.problems.push({ source: 'pics', code: e.code || 'pics.upstream_error', detail: e.detail || e.message });
+ }
+ res.json(out);
+ }));
+
+ r.get('/files/:source/:id/bytes', wrap(async (req, res) => {
+ const value = token(req, res);
+ if (!value) return;
+ const source = req.params.source;
+ const id = req.params.id;
+ const file = source === 'pics' ? await pics.bytes(id) : await media.downloadBytes(value, id);
+ const name = String(file.name || 'file').replace(/[^A-Za-z0-9._-]+/g, '_');
+ res.set('Content-Type', file.contentType || 'application/octet-stream');
+ res.set('Content-Disposition', 'attachment; filename="' + name + '"');
+ res.set('Cache-Control', 'private, no-store');
+ res.send(file.bytes);
+ }));
+
+ r.post('/save', express.raw({ type: '*/*', limit: '256mb' }), wrap(async (req, res) => {
+ const value = token(req, res);
+ if (!value) return;
+ const body = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
+ if (!body.length) {
+ res.status(422).type('application/problem+json').json({
+ type: 'about:blank', title: 'Nothing to save', status: 422, code: 'studio.save.empty',
+ });
+ return;
+ }
+ const name = String(req.get('x-file-name') || 'export');
+ const contentType = req.get('content-type') || 'application/octet-stream';
+ const file = await media.uploadFile(value, { name: name, contentType: contentType, bytes: body });
+ res.status(201).json({ file });
+ }));
+
+ return r;
+}
+
+module.exports = { createApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
+'use strict';
+const path = require('path');
+const express = require('express');
+const helmet = require('helmet');
+const { createSsoClient } = require('openvibe-sdk/sso');
+const { createApi } = require('./api');
+const { createPages } = require('./pages');
+const { createBundleFiles } = require('./bundle-files');
+const { createMediaClient } = require('./openvibe/media');
+const { createPicsClient } = require('./openvibe/pics');
+const configLib = require('./config');
+
+function createApp(opts = {}) {
+ const config = opts.config || configLib.load();
+ const sso = opts.sso || createSsoClient({
+ site: 'studio', baseUrl: config.baseUrl, clientId: config.clientId, clientSecret: config.clientSecret,
+ networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl, secureCookies: config.secureCookies,
+ });
+ const media = opts.media || createMediaClient({ baseUrl: config.downloadUrl, fetchImpl: opts.fetchImpl });
+ const pics = opts.pics || createPicsClient({ baseUrl: config.picsUrl, 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'", "'wasm-unsafe-eval'", '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.download', 'https://openvibe.pics'],
+ frameSrc: ["'self'"],
+ frameAncestors: ["'none'"],
+ objectSrc: ["'none'"],
+ workerSrc: ["'self'", 'blob:'],
+ 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-studio', version: require('../package.json').version, drill_port: config.drillPort,
+ }));
+ app.get('/robots.txt', (_req, res) => res.type('text/plain').send('User-agent: *\nAllow: /\nDisallow: /api/\nSitemap: https://openvibe.studio/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.studio/</loc></url><url><loc>https://openvibe.studio/photo</loc></url>' +
+ '<url><loc>https://openvibe.studio/light</loc></url><url><loc>https://openvibe.studio/attribution</loc></url></urlset>'));
+ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'studio', service: 'studio', host: 'openvibe.studio', name: 'OpenVibe.Studio', profile: 'hosting' })); }
+ app.get('/llms.txt', (_req, res) => res.type('text/plain').send(
+ '# OpenVibe.Studio\n\nPhoto and Light: two open-source creative apps (PhotoCraft, LightCraft) built from pinned release tags and served inside an OpenVibe account.\n\n- [Home](/)\n- [Photo](/photo)\n- [Light](/light)\n- [Attribution](/attribution)\n- [API docs](/openapi.json)\n'));
+ app.get('/openapi.json', (_req, res) => res.sendFile(path.join(__dirname, '..', 'public', 'openapi.json')));
+ app.use('/api/v1/studio', createApi({ sso, media, pics }));
+ app.use('/shared', require('openvibe-shared/serve').handler());
+ app.use('/apps', createBundleFiles({ root: config.bundleRoot }));
+ app.use(express.static(path.join(__dirname, '..', 'public'), { index: false, maxAge: '1h' }));
+ app.use(createPages({ config, sso, media, pics }));
+ app.use((err, _req, res, _next) => {
+ if (res.headersSent) return;
+ res.status(err.status || 500).type('text/plain').send('Studio could not complete that request.');
+ });
+ return { app, config, sso, media, pics };
+}
+
+module.exports = { createApp };
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+'use strict';
+const fs = require('fs');
+const { bundleHeaders, bundlePath } = require('./bundles');
+
+/**
+ * The app bundles are static files with rules of their own: the WebAssembly module needs the
+ * application/wasm type, hashed parts may be cached forever and the loader may not be. The
+ * bundles live outside git; the deploy fetches them from the build artifacts by tag and checksum.
+ */
+function createBundleFiles({ root }) {
+ return (req, res, next) => {
+ const rel = decodeURIComponent(req.path || '').replace(/^\/+/, '');
+ const parts = rel.split('/').filter(Boolean);
+ const appId = parts.shift() || '';
+ const rest = parts.join('/');
+ const file = bundlePath(root, appId, rest);
+ if (!file) return next();
+ let stat;
+ try {
+ stat = fs.statSync(file);
+ } catch {
+ return next();
+ }
+ if (!stat.isFile()) return next();
+ const headers = bundleHeaders(appId, rest);
+ for (const [key, value] of Object.entries(headers)) res.setHeader(key, value);
+ const etag = '"' + stat.size.toString(16) + '-' + Math.floor(stat.mtimeMs).toString(16) + '"';
+ res.setHeader('ETag', etag);
+ res.setHeader('Last-Modified', stat.mtime.toUTCString());
+ res.setHeader('Content-Length', stat.size);
+ if (req.headers['if-none-match'] === etag) {
+ res.status(304).end();
+ return;
+ }
+ if (req.method === 'HEAD') {
+ res.status(200).end();
+ return;
+ }
+ res.status(200);
+ fs.createReadStream(file).pipe(res);
+ };
+}
+
+module.exports = { createBundleFiles };
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+'use strict';
+const fs = require('fs');
+const path = require('path');
+
+const MIME = {
+ '.wasm': 'application/wasm',
+ '.js': 'text/javascript; charset=utf-8',
+ '.mjs': 'text/javascript; charset=utf-8',
+ '.html': 'text/html; charset=utf-8',
+ '.css': 'text/css; charset=utf-8',
+ '.json': 'application/json; charset=utf-8',
+ '.txt': 'text/plain; charset=utf-8',
+ '.otf': 'font/otf',
+ '.woff': 'font/woff',
+ '.woff2': 'font/woff2',
+ '.svg': 'image/svg+xml',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.webp': 'image/webp',
+ '.ico': 'image/x-icon',
+};
+
+// A name that carries a content hash never changes; a plain name may change with every release.
+const HASHED = /-[0-9a-f]{8,}./;
+const PART = /.part-[0-9a-f]+$/;
+
+/**
+ * The headers one file of a bundle is served with, following each upstream project's own rules
+ * (packaging/web/_headers): PhotoCraft's hashed parts are immutable, everything else is
+ * revalidated, and the WebAssembly module is served as application/wasm or the browser refuses
+ * to stream-compile it.
+ */
+function bundleHeaders(appId, relPath) {
+ const name = path.basename(relPath || '');
+ const ext = path.extname(name).toLowerCase();
+ const headers = { 'X-Content-Type-Options': 'nosniff' };
+ if (MIME[ext]) headers['Content-Type'] = MIME[ext];
+ const immutable = appId === 'photo' && (PART.test(name) || HASHED.test(name) || ext === '.wasm');
+ headers['Cache-Control'] = immutable ? 'public, max-age=31536000, immutable' : 'no-cache';
+ return headers;
+}
+
+/** The file a request for `rel` inside a bundle maps to, or null when it would leave the bundle. */
+function bundlePath(root, appId, rel) {
+ const base = path.resolve(root, String(appId || ''));
+ const target = path.resolve(base, String(rel || ''));
+ if (target !== base && !target.startsWith(base + path.sep)) return null;
+ return target;
+}
+
+/** Whether a bundle is installed on this machine (the deploy fetches it; it is never in git). */
+function hasBundle(root, appId) {
+ try {
+ return fs.statSync(path.join(root, appId, 'index.html')).isFile();
+ } catch {
+ return false;
+ }
+}
+
+module.exports = { bundleHeaders, bundlePath, hasBundle, MIME };
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
+'use strict';
+require('dotenv').config();
+const trim = (s) => String(s || '').replace(/\/+$/, '');
+const path = require('path');
+
+function load(env = process.env) {
+ const port = Number.parseInt(env.PORT, 10) || 5155;
+ 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}`),
+ drillPort: Number.parseInt(env.DRILL_PORT, 10) || port + 10000,
+ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
+ networkUrl,
+ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
+ downloadUrl: trim(env.OV_DOWNLOAD_URL || 'https://openvibe.download'),
+ picsUrl: trim(env.OV_PICS_URL || 'https://openvibe.pics'),
+ clientId: env.OV_OAUTH_CLIENT_ID || 'studio',
+ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
+ secureCookies: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : env.NODE_ENV === 'production',
+ bundleRoot: env.STUDIO_BUNDLE_ROOT || path.join(__dirname, '..', 'public', 'apps'),
+ };
+}
+module.exports = { load };
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+'use strict';
+const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
+module.exports = { esc };
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
+'use strict';
+const { createApp } = require('./app');
+const { app, config } = createApp();
+app.listen(config.port, config.host, () => console.log(`[Studio] listening on ${config.host}:${config.port} (drill ${config.drillPort})`));
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+'use strict';
+const shell = require('openvibe-shared/shell');
+const serve = require('openvibe-shared/serve');
+const NAV = [
+ { label: 'Home', href: '/' },
+ { label: 'Photo', href: '/photo' },
+ { label: 'Light', href: '/light' },
+];
+function page({ title, body, viewer, config, path = '/' }) {
+ const nav = { service: 'studio', 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: 'studio', variant: 'full', brandName: 'OpenVibe.Studio' };
+ return shell.page({ name: 'OpenVibe.Studio', lang: 'en', title: `${title} · OpenVibe.Studio`, titleSuffix: '', siteName: 'OpenVibe.Studio', description: 'Photo and Light, the creative apps on the OpenVibe network.', 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/studio.css"><meta name="referrer" content="strict-origin-when-cross-origin">`,
+ body: `<div id="navbar-mount"></div><main id="main" class="studio-page">${body}</main>` });
+}
+function send(res, opts, status = 200) { res.set('Cache-Control', opts.viewer?.kind === 'user' ? 'private, no-store' : 'public, max-age=60'); res.set('Vary', 'Cookie'); res.status(status).type('html').send(page(opts)); }
+module.exports = { page, send, NAV };
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
+'use strict';
+
+class StudioError extends Error {
+ constructor(status, code, detail) {
+ super(detail || code);
+ this.status = status || 502;
+ this.code = code || 'studio.upstream_error';
+ this.detail = detail || '';
+ }
+}
+
+/**
+ * OpenVibe.Download (MediaHub): the files a person owns, and the upload that puts a file there.
+ * The API accepts a person's Network token as a Bearer; the byte route for a file does not, so
+ * bytes come through a public share link (see downloadBytes).
+ */
+function createMediaClient({ baseUrl, fetchImpl = globalThis.fetch }) {
+ const root = String(baseUrl || '').replace(/\/+$/, '');
+
+ async function request(token, path, options = {}) {
+ const res = await fetchImpl(root + '/api/v1' + path, {
+ method: options.method || 'GET',
+ headers: Object.assign({ authorization: 'Bearer ' + token, accept: 'application/json' }, options.headers || {}),
+ ...(options.body !== undefined ? { body: options.body } : {}),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new StudioError(res.status, data.code || 'media.upstream_error', data.detail || data.title || 'OpenVibe.Download returned ' + res.status);
+ }
+ return res.json().catch(() => ({}));
+ }
+
+ return {
+ root,
+ async listFiles(token, options = {}) {
+ const limit = Math.min(200, Math.max(1, Number(options.limit) || 50));
+ const data = await request(token, '/files?limit=' + encodeURIComponent(limit));
+ return {
+ files: Array.isArray(data.files) ? data.files : [],
+ folders: Array.isArray(data.folders) ? data.folders : [],
+ usage: data.usage || null,
+ };
+ },
+ async getFile(token, id) {
+ const data = await request(token, '/files/' + encodeURIComponent(id));
+ return { file: data.file || null, shares: Array.isArray(data.shares) ? data.shares : [] };
+ },
+ async downloadBytes(token, id) {
+ const info = await this.getFile(token, id);
+ const file = info.file;
+ if (!file) throw new StudioError(404, 'media.file_not_found', 'No such file in your OpenVibe.Download drive.');
+ const share = info.shares.find((one) => one && (one.public === true || one.state === 'public'));
+ if (!share) {
+ throw new StudioError(501, 'media.bytes.unavailable',
+ 'OpenVibe.Download has no route that hands a file\'s bytes to a person\'s token yet. Share the file publicly and it can be opened here.');
+ }
+ const res = await fetchImpl(root + '/s/' + encodeURIComponent(share.slug) + '/f/' + encodeURIComponent(file.id) + '/download', { headers: { accept: '*/*' } });
+ if (!res.ok) throw new StudioError(res.status, 'media.bytes.unavailable', 'OpenVibe.Download returned ' + res.status + ' for the share link.');
+ return { name: file.name, contentType: file.content_type || 'application/octet-stream', bytes: Buffer.from(await res.arrayBuffer()) };
+ },
+ async uploadFile(token, file) {
+ const bytes = Buffer.isBuffer(file.bytes) ? file.bytes : Buffer.from(file.bytes || []);
+ const name = String(file.name || 'export');
+ const contentType = file.contentType || 'application/octet-stream';
+ const start = await request(token, '/files', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ name: name, size: bytes.length, content_type: contentType }),
+ });
+ const upload = start.upload || {};
+ if (!upload.id) throw new StudioError(502, 'media.upload.unavailable', 'OpenVibe.Download did not start an upload.');
+ const partSize = Number(upload.part_size) || bytes.length || 1;
+ const expected = Number(upload.parts_expected) || 1;
+ const missing = Array.isArray(upload.missing) && upload.missing.length ? upload.missing : Array.from({ length: expected }, (_, index) => index + 1);
+ for (const part of missing) {
+ const from = (part - 1) * partSize;
+ const to = Math.min(bytes.length, from + partSize);
+ await request(token, '/uploads/' + encodeURIComponent(upload.id) + '/parts/' + part, {
+ method: 'PUT',
+ headers: { 'content-type': 'application/octet-stream' },
+ body: bytes.subarray(from, to),
+ });
+ }
+ const done = await request(token, '/uploads/' + encodeURIComponent(upload.id) + '/complete', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: '{}',
+ });
+ return done.file || { name: name, size: bytes.length };
+ },
+ };
+}
+
+module.exports = { createMediaClient, StudioError };
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+'use strict';
+const { StudioError } = require('./media');
+
+/**
+ * OpenVibe.Pics: the pictures a person owns. The image bytes are public (the site serves them
+ * under /i/<slug>), so a picture can be fetched without the owner's session.
+ */
+function createPicsClient({ baseUrl, fetchImpl = globalThis.fetch }) {
+ const root = String(baseUrl || '').replace(/\/+$/, '');
+
+ async function request(path, token) {
+ const res = await fetchImpl(root + '/api/v1' + path, {
+ headers: Object.assign({ accept: 'application/json' }, token ? { authorization: 'Bearer ' + token } : {}),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new StudioError(res.status, data.code || 'pics.upstream_error', data.detail || data.title || 'OpenVibe.Pics returned ' + res.status);
+ }
+ return res.json().catch(() => ({}));
+ }
+
+ const wire = (image) => ({
+ id: image.slug || image.id || null,
+ slug: image.slug || null,
+ name: image.name || image.title || image.slug || 'image',
+ contentType: image.content_type || 'image/jpeg',
+ direct: (image.links && image.links.direct) || null,
+ page: (image.links && image.links.page) || null,
+ thumb: (image.links && image.links.thumb) || null,
+ });
+
+ return {
+ root,
+ async listImages(token) {
+ const data = await request('/me/images', token);
+ const images = Array.isArray(data.images) ? data.images : (Array.isArray(data) ? data : []);
+ return images.map(wire);
+ },
+ async bytes(slug) {
+ const data = await request('/images/' + encodeURIComponent(slug));
+ const image = wire(data.image || data);
+ const direct = image.direct || (image.slug ? root + '/i/' + image.slug : null);
+ if (!direct) throw new StudioError(404, 'pics.image_not_found', 'No such picture in OpenVibe.Pics.');
+ const res = await fetchImpl(direct, { headers: { accept: '*/*' } });
+ if (!res.ok) throw new StudioError(res.status, 'pics.bytes.unavailable', 'OpenVibe.Pics returned ' + res.status + ' for the picture.');
+ return { name: image.name, contentType: image.contentType, bytes: Buffer.from(await res.arrayBuffer()) };
+ },
+ };
+}
+
+module.exports = { createPicsClient };
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
+'use strict';
+const express = require('express');
+const { esc } = require('./html');
+const { send } = require('./layout');
+const { ADAPTERS } = require('./adapters');
+const { hasBundle } = require('./bundles');
+const { APPS } = require('./product');
+
+function createPages({ config, sso }) {
+ const r = express.Router();
+ const viewerOf = (req) => req.user || req.viewer || { kind: 'anonymous' };
+
+ // The home is short (Opus's design pass, 11-08): one promise, two apps with three features each, three plain notes,
+ // one credit line. The measured detail lives on each app's page and in the docs, not here.
+ const HOME_APPS = [
+ { id: 'photo', name: 'Photo', line: 'Layers, masks and retouching.', features: ['Layers and masks', 'Curves and adjustments', 'Opens PSD files'] },
+ { id: 'light', name: 'Light', line: 'Your photo library and RAW developer.', features: ['RAW files', 'Develop and presets', 'A library in your browser'] },
+ ];
+ const appCard = (app) => '<article class="app-card"><h3>' + esc(app.name) + '</h3><p>' + esc(app.line) + '</p>' +
+ '<ul class="app-features">' + app.features.map((f) => '<li>' + esc(f) + '</li>').join('') + '</ul>' +
+ '<p><a class="button-primary" href="/' + esc(app.id) + '">Open ' + esc(app.name) + '</a></p></article>';
+
+ r.get('/', (req, res) => {
+ const viewer = viewerOf(req);
+ const photo = APPS.find((x) => x.id === 'photo'), light = APPS.find((x) => x.id === 'light');
+ const body = '<section class="hero"><div>' +
+ '<span class="eyebrow">OPENVIBE STUDIO</span>' +
+ '<h1>Edit photos right in your browser.</h1>' +
+ '<p>Photo for layers and retouching. Light for your library and RAW files. Free and open source, with your files on OpenVibe.</p>' +
+ '<p><a class="button-primary" href="/photo">Open Photo</a> <a class="button-secondary" href="/light">Open Light</a></p>' +
+ '</div></section>' +
+ '<section id="apps"><div class="app-grid">' + HOME_APPS.map(appCard).join('') + '</div></section>' +
+ '<section id="limits"><h2>Good to know</h2><ul class="limit-list">' +
+ '<li>The first open takes a few seconds while the app loads.</li>' +
+ '<li>On a phone with 4 GB of memory, very large files can run out of memory.</li>' +
+ '<li>It needs a connection: there is no offline mode yet. Video editing comes later.</li></ul></section>' +
+ '<section id="licences"><p class="fine-print">Photo is <a href="' + esc(photo.upstream) + '" rel="noopener">PhotoCraft</a> and Light is ' +
+ '<a href="' + esc(light.upstream) + '" rel="noopener">LightCraft</a>, open-source apps (' + esc(photo.licence) + ') that we build from their releases. ' +
+ '<a href="/attribution">Attribution</a></p></section>';
+ send(res, { title: 'Studio: edit photos in your browser', body: body, viewer: viewer, config: config, path: '/' });
+ });
+
+ const appPage = (adapter, viewer) => {
+ const installed = hasBundle(config.bundleRoot, adapter.id);
+ const src = '/apps/' + adapter.bundle.path + '/' + adapter.bundle.entry + (adapter.bundle.query || '');
+ const commands = adapter.commands.map((command) =>
+ '<button class="button-quiet" type="button" data-command="' + esc(command.id) + '" title="' + esc(command.description || command.title) + '">' + esc(command.title) + '</button>').join('');
+ const saveNote = adapter.capabilities.save ? '' :
+ 'Save to OpenVibe is not available for this app: its browser build has no scriptable export, so export from the app and the file downloads to your browser.';
+ const head = '<section class="app-head"><div>' +
+ '<span class="eyebrow">' + esc(adapter.title) + '</span>' +
+ '<h1>' + esc(adapter.title) + '</h1>' +
+ '<p>Build ' + esc(adapter.base.tag) + ' of ' + esc(adapter.base.repository.replace('https://github.com/', '')) +
+ ', built by ' + esc(adapter.base.builtBy) + '. Licence: ' + esc(adapter.base.licence) + '.</p>' +
+ '</div><div class="app-actions">' +
+ '<button class="button-secondary" type="button" data-action="open">Open from OpenVibe</button>' +
+ '<button class="button-secondary" type="button" data-action="save"' + (adapter.capabilities.save ? '' : ' disabled') + '>Save to OpenVibe</button>' +
+ (commands ? '<span class="app-commands">' + commands + '</span>' : '') +
+ '</div>' +
+ '<p class="app-status" data-role="status" aria-live="polite"></p>' +
+ (saveNote ? '<p class="fine-print">' + esc(saveNote) + '</p>' : '') +
+ '</section>';
+ const files = '<div class="app-files" data-role="files" hidden><h2>Your OpenVibe files</h2>' +
+ '<ul data-role="file-list"></ul><p data-role="files-note" class="fine-print"></p></div>' +
+ '<pre class="app-output" data-role="output" hidden></pre>';
+ const frame = installed
+ ? '<div class="app-frame-wrap"><iframe class="app-frame" src="' + esc(src) + '" title="' + esc(adapter.title) +
+ '" allow="fullscreen; clipboard-read; clipboard-write" allowfullscreen></iframe></div>'
+ : '<p class="app-missing">The ' + esc(adapter.title) + ' bundle is not installed on this server yet. ' +
+ 'The deploy fetches it from the build artifacts by tag and checksum; a bundle is never kept in git.</p>';
+ const scripts = '<script src="/js/drivers.js"></script><script src="/js/studio.js"></script>';
+ return '<section class="app-shell" data-studio-app="' + esc(adapter.id) + '" data-studio-driver="' + esc(adapter.driver) +
+ '" data-studio-signed-in="' + (viewer.kind === 'user' ? 'true' : 'false') + '">' + head + files + frame + '</section>' + scripts;
+ };
+
+ r.get('/:app(photo|light)', (req, res) => {
+ const adapter = ADAPTERS.find((one) => one.id === req.params.app);
+ if (!adapter) {
+ send(res, {
+ title: 'No such app',
+ body: '<section class="page-intro"><h1>No such app</h1><p><a href="/">Back to Studio</a></p></section>',
+ viewer: viewerOf(req), config: config, path: '/',
+ }, 404);
+ return;
+ }
+ const viewer = viewerOf(req);
+ send(res, { title: adapter.title + ' in Studio', body: appPage(adapter, viewer), viewer: viewer, config: config, path: '/' + adapter.id });
+ });
+
+ r.get('/attribution', (req, res) => {
+ const rows = ADAPTERS.map((adapter) => '<li><strong>' + esc(adapter.base.name) + '</strong> — ' + esc(adapter.base.licence) +
+ '. Built by ' + esc(adapter.base.builtBy) + ' from <a href="' + esc(adapter.base.repository) + '" rel="noopener">' + esc(adapter.base.repository) +
+ '</a> at tag ' + esc(adapter.base.tag) + '. Licence files kept with the build: ' +
+ esc(adapter.base.licenceFiles.join(', ')) + '.</li>').join('');
+ const body = '<section class="page-intro"><span class="eyebrow">CREDIT</span><h1>Attribution</h1>' +
+ '<p>Studio hosts builds of open-source projects. Each one keeps its own licence and its own attribution files; here they are in full.</p></section>' +
+ '<ul class="limit-list">' + rows + '</ul>' +
+ '<p class="fine-print">OpenVibe.Studio is MIT licensed. The apps are the work of their own authors and are used under their own terms; ' +
+ 'OpenVibe.Studio hosts a build of them and is not otherwise connected to the upstream projects.</p>';
+ send(res, { title: 'Attribution', body: body, viewer: viewerOf(req), config: config, path: '/attribution' });
+ });
+
+ return r;
+}
+
+module.exports = { createPages };
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+'use strict';
+
+/** What the home page says about each app. The limits are measured, not guessed. */
+const APPS = [
+ {
+ id: 'photo',
+ title: 'PhotoCraft',
+ tagline: 'A layered photo editor, in the browser.',
+ what: 'PhotoCraft is an open-source photo editor with layers, masks, curves and RAW development. Studio serves a build of it inside an OpenVibe page: sign in, drop in a file from your OpenVibe library, and work.',
+ limits: [
+ 'Heavy first load. The app ships its module as gzip parts, so the first frame takes seconds on a slow line.',
+ 'Memory. A 4 GB Photoshop file passed the app\'s 4 GB cap but used 2.7-3.0 GB of memory; a phone with 4 GB can run out of memory on big files.',
+ 'No scriptable export in the browser build. Save to OpenVibe is not offered for Photo: export from the app and the file downloads to your browser.',
+ ],
+ upstream: 'https://github.com/storytold/photocraft',
+ licence: 'MIT OR Apache-2.0',
+ licenceNote: 'The upstream repository ships LICENSE-MIT and LICENSE-APACHE (your choice), plus NOTICE and ATTRIBUTION.md. Our build keeps those files.',
+ },
+ {
+ id: 'light',
+ title: 'LightCraft',
+ tagline: 'A photo library and RAW developer, in the browser.',
+ what: 'LightCraft is an open-source photo library and RAW developer: a catalogue, develop controls and exports, with the library kept in your own browser storage. Studio serves a build of it inside an OpenVibe page, so a photo can come from your OpenVibe library and an export can go back to it.',
+ limits: [
+ 'Heavy first load. The module is about 13.5 MB before compression (about 3.1 MB over Brotli) and is compiled before the first frame.',
+ 'Our build leaves out the optional 16 MB Chinese font, so Chinese and Japanese text has no glyphs. Everything else works.',
+ 'The library lives in your browser\'s storage for this site. Clearing site data empties it; keep your originals elsewhere.',
+ 'Online only. Neither app caches for offline use, and neither works from a file:// page.',
+ ],
+ upstream: 'https://github.com/storytold/lightcraft',
+ licence: 'MIT OR Apache-2.0',
+ licenceNote: 'The upstream repository ships LICENSE-MIT and LICENSE-APACHE (your choice), plus NOTICE and ATTRIBUTION.md. Our build keeps those files.',
+ },
+];
+
+/** The limits both apps share, measured on the reference machine in the Studio due diligence. */
+const LIMITS = [
+ 'Heavy first load: both apps are native editors compiled to WebAssembly. Measured time to first frame was about 2.5 s at 1280 px and 0.6 s at 390 px for LightCraft, and about 1.3 s for PhotoCraft once the loader path is fixed.',
+ 'Phones with 4 GB of RAM can run out of memory on big files. Both apps run on a phone; neither is light.',
+ 'Works online only. Neither app ships a service worker, so nothing loads without a connection.',
+ 'Cut (FilmCraft) is not part of Studio v0. It stays a desktop and agent tool.',
+ 'No fake pictures: this page shows a capture of an app only when it comes from a running build.',
+];
+
+function appById(id) {
+ return APPS.find((app) => app.id === id) || null;
+}
+
+module.exports = { APPS, LIMITS, appById };
new file mode 100644
new file mode 100644
@@ -0,0 +1,128 @@
@@ -0,0 +1,128 @@
+'use strict';
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { ADAPTERS, byId, assertAdapter, assertDriver } = require('../server/adapters');
+const drivers = require('../public/js/drivers.js');
+
+test('every adapter keeps the same contract, so a base can be swapped', () => {
+ assert.equal(ADAPTERS.length, 2);
+ for (const adapter of ADAPTERS) {
+ assert.doesNotThrow(() => assertAdapter(adapter));
+ assert.ok(adapter.base.repository.startsWith('https://github.com/'));
+ assert.match(adapter.base.tag, /^v\d+\.\d+\.\d+$/);
+ assert.ok(adapter.base.licenceFiles.length > 0);
+ assert.ok(adapter.base.builtBy.length > 0);
+ assert.equal(typeof adapter.bundle.path, 'string');
+ assert.equal(typeof adapter.bundle.entry, 'string');
+ }
+ assert.equal(byId('light').title, 'LightCraft');
+ assert.equal(byId('nope'), null);
+});
+
+test('an adapter that is missing a part of the contract is refused', () => {
+ const broken = Object.assign({}, byId('light'));
+ delete broken.base;
+ assert.throws(() => assertAdapter(broken), /base/);
+ const noCommands = Object.assign({}, byId('light'), { commands: [{ id: 'x' }] });
+ assert.throws(() => assertAdapter(noCommands), /command/);
+});
+
+function fakeAppWindow() {
+ const events = [];
+ const dropped = [];
+ class FakeFile {
+ constructor(parts, name, options) { this.parts = parts; this.name = name; this.type = (options || {}).type; }
+ }
+ class FakeDataTransfer {
+ constructor() { this.items = { add: (file) => dropped.push(file) }; }
+ }
+ class FakeDragEvent {
+ constructor(type, init) { this.type = type; this.dataTransfer = (init || {}).dataTransfer; }
+ }
+ return {
+ events, dropped,
+ DataTransfer: FakeDataTransfer,
+ File: FakeFile,
+ DragEvent: FakeDragEvent,
+ document: { dispatchEvent: (event) => { events.push(event); return true; } },
+ };
+}
+
+function fakeCtx(overrides = {}) {
+ const sent = [];
+ const win = fakeAppWindow();
+ const ctx = {
+ origin: 'https://studio.test',
+ window: { postMessage: (message) => sent.push(message) },
+ iframe: { contentWindow: null },
+ nextSave: async () => ({ ok: true, file: { name: 'export.jpg', bytes: new Uint8Array([1, 2, 3]) } }),
+ nextResponse: async () => ({ ok: true, value: '{"photos":3}' }),
+ };
+ return Object.assign(ctx, overrides, { sent, win });
+}
+
+test('every driver keeps the same contract', () => {
+ for (const [name, make] of Object.entries(drivers)) {
+ const driver = make();
+ assert.doesNotThrow(() => assertDriver(driver));
+ assert.equal(driver.id, name);
+ }
+});
+
+test('the light driver opens a file through the documented bridge', async () => {
+ const ctx = fakeCtx();
+ const result = await drivers.lightcraft().open(ctx, { name: 'sunset.jpg', bytes: new Uint8Array([1, 2, 3]) });
+ assert.equal(result.ok, true);
+ assert.equal(ctx.sent.length, 1);
+ assert.equal(ctx.sent[0].type, 'lightcraft:open');
+ assert.equal(ctx.sent[0].name, 'sunset.jpg');
+ assert.ok(ctx.sent[0].bytes instanceof Uint8Array);
+});
+
+test('the light driver asks the app to export and waits for the file the app hands over', async () => {
+ const ctx = fakeCtx();
+ const result = await drivers.lightcraft().save(ctx);
+ assert.equal(result.ok, true);
+ assert.equal(result.file.name, 'export.jpg');
+ assert.equal(ctx.sent[0].type, 'lightcraft:cmd');
+ assert.equal(ctx.sent[0].id, 'app.export');
+});
+
+test('the light driver reports a command that failed, with the app\'s own words', async () => {
+ const ctx = fakeCtx({ nextResponse: async () => ({ ok: false, value: 'unknown command' }) });
+ const result = await drivers.lightcraft().run(ctx, 'library.info', {});
+ assert.equal(result.ok, false);
+ assert.match(result.detail, /unknown command/);
+ assert.equal(ctx.sent[0].id, 'library.info');
+ assert.equal(ctx.sent[0].params, '{}');
+});
+
+test('the photo driver drops the file on the app and says it cannot save', async () => {
+ const ctx = fakeCtx();
+ const win = fakeAppWindow();
+ ctx.window = win;
+ const result = await drivers.photocraft().open(ctx, { name: 'sunset.jpg', bytes: new Uint8Array([1, 2, 3]) });
+ assert.equal(result.ok, true);
+ assert.deepEqual(win.events.map((event) => event.type), ['dragenter', 'dragover', 'drop']);
+ assert.equal(win.dropped.length, 1);
+ assert.equal(win.dropped[0].name, 'sunset.jpg');
+ const save = await drivers.photocraft().save(ctx);
+ assert.equal(save.ok, false);
+ assert.equal(save.code, 'photo.save.unsupported');
+ const run = await drivers.photocraft().run(ctx, 'library.info', {});
+ assert.equal(run.ok, false);
+});
+
+test('a fake app is enough to drive every driver, and each one answers', async () => {
+ for (const [name, make] of Object.entries(drivers)) {
+ const ctx = fakeCtx();
+ ctx.window = Object.assign(fakeAppWindow(), { postMessage: (message) => ctx.sent.push(message) });
+ const driver = make();
+ const opened = await driver.open(ctx, { name: 'a.jpg', bytes: new Uint8Array([9]) });
+ assert.equal(typeof opened.ok, 'boolean', name + ' open');
+ const saved = await driver.save(ctx);
+ assert.equal(typeof saved.ok, 'boolean', name + ' save');
+ const ran = await driver.run(ctx, 'library.info', {});
+ assert.equal(typeof ran.ok, 'boolean', name + ' run');
+ }
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const { createMediaClient } = require('../server/openvibe/media');
+
+function fakeFetch(routes) {
+ const seen = [];
+ const fetchImpl = async (url, options = {}) => {
+ seen.push([url, options.method || 'GET', options.body ? Buffer.from(options.body).toString('utf8') : null]);
+ const route = routes[url];
+ if (!route) return { ok: false, status: 404, json: async () => ({ detail: 'no route' }) };
+ return typeof route === 'function' ? route(options) : route;
+ }
+ return { fetchImpl, seen };
+}
+
+const ok = (body, status = 200) => ({ ok: true, status, json: async () => body });
+
+test('the files of a person are listed with the bearer token', async () => {
+ const { fetchImpl, seen } = fakeFetch({
+ 'https://openvibe.download/api/v1/files?limit=10': ok({ files: [{ id: 'f1', name: 'a.jpg' }], folders: [], usage: { bytes: 3 } }),
+ });
+ const media = createMediaClient({ baseUrl: 'https://openvibe.download', fetchImpl });
+ const data = await media.listFiles('person-token', { limit: 10 });
+ assert.equal(data.files[0].name, 'a.jpg');
+ assert.equal(seen[0][1], 'GET');
+});
+
+test('a save is a start, the parts and a complete', async () => {
+ const { fetchImpl, seen } = fakeFetch({
+ 'https://openvibe.download/api/v1/files': ok({ upload: { id: 'up1', part_size: 4, parts_expected: 2, missing: [1, 2] } }, 201),
+ 'https://openvibe.download/api/v1/uploads/up1/parts/1': ok({}),
+ 'https://openvibe.download/api/v1/uploads/up1/parts/2': ok({}),
+ 'https://openvibe.download/api/v1/uploads/up1/complete': ok({ file: { id: 'file_new', name: 'export.jpg' } }),
+ });
+ const media = createMediaClient({ baseUrl: 'https://openvibe.download', fetchImpl });
+ const file = await media.uploadFile('person-token', { name: 'export.jpg', contentType: 'image/jpeg', bytes: Buffer.from('abcdefgh') });
+ assert.equal(file.name, 'export.jpg');
+ const paths = seen.map((entry) => entry[0] + ' ' + entry[1]);
+ assert.deepEqual(paths, [
+ 'https://openvibe.download/api/v1/files POST',
+ 'https://openvibe.download/api/v1/uploads/up1/parts/1 PUT',
+ 'https://openvibe.download/api/v1/uploads/up1/parts/2 PUT',
+ 'https://openvibe.download/api/v1/uploads/up1/complete POST',
+ ]);
+ assert.equal(seen[1][2], 'abcd');
+ assert.equal(seen[2][2], 'efgh');
+});
+
+test('the bytes of a file come through a public share, or the layer says so', async () => {
+ const { fetchImpl } = fakeFetch({
+ 'https://openvibe.download/api/v1/files/f1': ok({ file: { id: 'f1', name: 'a.jpg', content_type: 'image/jpeg' }, shares: [] }),
+ });
+ const media = createMediaClient({ baseUrl: 'https://openvibe.download', fetchImpl });
+ await assert.rejects(() => media.downloadBytes('person-token', 'f1'), (error) => {
+ assert.equal(error.status, 501);
+ assert.equal(error.code, 'media.bytes.unavailable');
+ assert.match(error.detail, /[Ss]hare/);
+ return true;
+ });
+
+ const shared = fakeFetch({
+ 'https://openvibe.download/api/v1/files/f1': ok({ file: { id: 'f1', name: 'a.jpg', content_type: 'image/jpeg' }, shares: [{ slug: 'abc', public: true }] }),
+ 'https://openvibe.download/s/abc/f/f1/download': { ok: true, status: 200, arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer },
+ });
+ const client = createMediaClient({ baseUrl: 'https://openvibe.download', fetchImpl: shared.fetchImpl });
+ const file = await client.downloadBytes('person-token', 'f1');
+ assert.equal(file.name, 'a.jpg');
+ assert.equal(file.bytes.length, 3);
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,188 @@
@@ -0,0 +1,188 @@
+'use strict';
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const http = require('node:http');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const express = require('express');
+const { createApp } = require('../server/app');
+
+const config = {
+ port: 0, host: '127.0.0.1', baseUrl: 'http://studio.test', networkUrl: 'https://openvibe.network',
+ networkInternalUrl: 'http://127.0.0.1:4000', downloadUrl: 'https://openvibe.download', picsUrl: 'https://openvibe.pics',
+ clientId: 'studio', clientSecret: '', secureCookies: false, trustProxy: 2, drillPort: 15155, bundleRoot: '',
+};
+
+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 match = (req.headers.cookie || '').match(/(?:^|;\s*)ov_token=([^;]+)/);
+ return (match && match[1]) || null;
+ },
+ };
+}
+
+const fakeMedia = {
+ calls: [],
+ listFiles: async (token) => { fakeMedia.calls.push(['list', token]); return { files: [{ id: 'file_a', name: 'sunset.jpg', content_type: 'image/jpeg', size: 12 }], folders: [], usage: { bytes: 12 } }; },
+ downloadBytes: async (token, id) => { fakeMedia.calls.push(['bytes', token, id]); throw Object.assign(new Error('no byte route'), { status: 501, code: 'media.bytes.unavailable', detail: 'no public share' }); },
+ uploadFile: async (token, file) => { fakeMedia.calls.push(['upload', token, file.name, file.bytes.length]); return { id: 'file_new', name: file.name, size: file.bytes.length }; },
+};
+
+const fakePics = {
+ listImages: async () => [{ id: 'img_a', slug: 'img_a', name: 'mountains.jpg' }],
+ bytes: async () => ({ name: 'mountains.jpg', contentType: 'image/jpeg', bytes: Buffer.from([1, 2, 3]) }),
+};
+
+function bundleFixture() {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'studio-bundles-'));
+ fs.mkdirSync(path.join(root, 'light'), { recursive: true });
+ fs.mkdirSync(path.join(root, 'photo', 'app'), { recursive: true });
+ fs.writeFileSync(path.join(root, 'light', 'index.html'), '<!doctype html><title>LightCraft</title>');
+ fs.writeFileSync(path.join(root, 'light', 'lightcraft_web_bg.wasm'), Buffer.from([0, 97, 115, 109]));
+ fs.writeFileSync(path.join(root, 'photo', 'index.html'), '<!doctype html><title>PhotoCraft</title>');
+ fs.writeFileSync(path.join(root, 'photo', 'app', 'parts.txt'), 'photocraft-parts 1\n');
+ fs.writeFileSync(path.join(root, 'photo', 'app', 'photocraft-web.js'), 'export {};\n');
+ fs.writeFileSync(path.join(root, 'photo', 'app', 'photocraft-web-abcdef12.part-0'), 'part');
+ return root;
+}
+
+async function serve(opts = {}) {
+ const { app } = createApp({
+ config: Object.assign({}, config, { bundleRoot: bundleFixture() }, opts.config || {}),
+ sso: fakeSso(),
+ media: opts.media || fakeMedia,
+ pics: opts.pics || fakePics,
+ });
+ const server = http.createServer(app);
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ return { base: 'http://127.0.0.1:' + server.address().port, close: () => new Promise((resolve) => server.close(resolve)) };
+}
+
+test('the home names both apps, their limits and their licences', async (t) => {
+ const s = await serve(); t.after(s.close);
+ const res = await fetch(s.base);
+ assert.equal(res.status, 200);
+ const html = await res.text();
+ assert.match(html, /PhotoCraft/);
+ assert.match(html, /LightCraft/);
+ assert.match(html, /Good to know/);
+ assert.match(html, /MIT OR Apache-2\.0/);
+ assert.match(html, /github\.com\/storytold\/photocraft/);
+ assert.match(html, /github\.com\/storytold\/lightcraft/);
+ assert.match(html, /Video editing comes later/);
+ assert.match(html, /4 GB/);
+ assert.doesNotMatch(html, /<img/);
+});
+
+test('the app pages wrap the app in the layer, with the sign-in state', async (t) => {
+ const s = await serve(); t.after(s.close);
+ const guest = await fetch(s.base + '/light');
+ const guestHtml = await guest.text();
+ assert.equal(guest.status, 200);
+ assert.match(guestHtml, /Open from OpenVibe/);
+ assert.match(guestHtml, /Save to OpenVibe/);
+ assert.match(guestHtml, /data-studio-app="light"/);
+ assert.match(guestHtml, /data-studio-driver="lightcraft"/);
+ assert.match(guestHtml, /data-studio-signed-in="false"/);
+ assert.match(guestHtml, /apps\/light\/index\.html\?host=parent/);
+
+ const person = await fetch(s.base + '/light', { headers: { cookie: 'ov_token=person-a' } });
+ assert.match(await person.text(), /data-studio-signed-in="true"/);
+
+ const photo = await fetch(s.base + '/photo');
+ const photoHtml = await photo.text();
+ assert.match(photoHtml, /data-studio-driver="photocraft"/);
+ assert.match(photoHtml, /no scriptable export/);
+});
+
+test('the bundles are served with the type and the cache rules the apps need', async (t) => {
+ const s = await serve(); t.after(s.close);
+ const wasm = await fetch(s.base + '/apps/light/lightcraft_web_bg.wasm');
+ assert.equal(wasm.status, 200);
+ assert.match(wasm.headers.get('content-type'), /application\/wasm/);
+ assert.equal(wasm.headers.get('cache-control'), 'no-cache');
+
+ const part = await fetch(s.base + '/apps/photo/app/photocraft-web-abcdef12.part-0');
+ assert.equal(part.status, 200);
+ assert.match(part.headers.get('cache-control'), /immutable/);
+
+ const loader = await fetch(s.base + '/apps/photo/app/photocraft-web.js');
+ assert.equal(loader.headers.get('cache-control'), 'no-cache');
+ assert.match(loader.headers.get('content-type'), /javascript/);
+
+ const list = await fetch(s.base + '/apps/photo/app/parts.txt');
+ assert.equal(list.headers.get('cache-control'), 'no-cache');
+ assert.match(list.headers.get('content-type'), /text\/plain/);
+
+ const page = await fetch(s.base + '/apps/photo/index.html');
+ assert.equal(page.headers.get('cache-control'), 'no-cache');
+
+ assert.equal((await fetch(s.base + '/apps/light/nothing-here')).status, 404);
+ assert.equal((await fetch(s.base + '/apps/../package.json')).status, 404);
+});
+
+test('saving needs a signed-in person', async (t) => {
+ fakeMedia.calls.length = 0;
+ const s = await serve(); t.after(s.close);
+ const guest = await fetch(s.base + '/api/v1/studio/save', { method: 'POST', headers: { 'content-type': 'image/jpeg' }, body: 'bytes' });
+ assert.equal(guest.status, 401);
+ assert.equal(fakeMedia.calls.filter((call) => call[0] === 'upload').length, 0);
+
+ const person = await fetch(s.base + '/api/v1/studio/save', {
+ method: 'POST',
+ headers: { cookie: 'ov_token=person-a', 'content-type': 'image/jpeg', 'x-file-name': 'export.jpg' },
+ body: 'bytes',
+ });
+ assert.equal(person.status, 201);
+ const upload = fakeMedia.calls.find((call) => call[0] === 'upload');
+ assert.deepEqual(upload, ['upload', 'person-a', 'export.jpg', 5]);
+});
+
+test('the files listing uses the person token and reports a source that fails', async (t) => {
+ fakeMedia.calls.length = 0;
+ const s = await serve(); t.after(s.close);
+ assert.equal((await fetch(s.base + '/api/v1/studio/files')).status, 401);
+ const res = await fetch(s.base + '/api/v1/studio/files', { headers: { cookie: 'ov_token=person-a' } });
+ assert.equal(res.status, 200);
+ const data = await res.json();
+ assert.equal(data.download.files[0].name, 'sunset.jpg');
+ assert.equal(data.pics.images[0].slug, 'img_a');
+ assert.deepEqual(data.problems, []);
+ assert.deepEqual(fakeMedia.calls[0], ['list', 'person-a']);
+
+ const broken = await serve({
+ media: Object.assign({}, fakeMedia, {
+ listFiles: async () => { throw Object.assign(new Error('nope'), { status: 502, code: 'media.upstream_error', detail: 'the drive is down' }); },
+ }),
+ });
+ t.after(broken.close);
+ const failed = await (await fetch(broken.base + '/api/v1/studio/files', { headers: { cookie: 'ov_token=person-a' } })).json();
+ assert.equal(failed.problems[0].source, 'download');
+ assert.equal(failed.problems[0].detail, 'the drive is down');
+});
+
+test('the app catalogue, the attribution page and the small endpoints answer', async (t) => {
+ const s = await serve(); t.after(s.close);
+ const apps = await (await fetch(s.base + '/api/v1/studio/apps')).json();
+ assert.deepEqual(apps.apps.map((one) => one.id), ['photo', 'light']);
+ assert.equal(apps.apps[1].capabilities.save, true);
+ assert.equal(apps.apps[0].capabilities.save, false);
+ const attribution = await fetch(s.base + '/attribution');
+ assert.equal(attribution.status, 200);
+ const attributionHtml = await attribution.text();
+ assert.match(attributionHtml, /storytold\/lightcraft/);
+ assert.match(attributionHtml, /v0\.19\.0/);
+ assert.equal((await fetch(s.base + '/api/health')).status, 200);
+ assert.equal((await fetch(s.base + '/llms.txt')).status, 200);
+ assert.equal((await fetch(s.base + '/robots.txt')).status, 200);
+ assert.equal((await fetch(s.base + '/nope')).status, 404);
+});