openvibers/openvibe.studio Commits

OpenVibe.Studio v0: Photo and Light (#1)

OP OpenVibers committed 4 days ago · b0ebf2514d · 42 files, +3854 −1

* OpenVibe.Studio: the service, the adapters and the builds

* Check the bundle build on pull requests that touch it

* Let the apps compile WebAssembly, and smoke test them through the service

* Studio home: one promise, two apps with three features each, three plain notes, one credit line

Opus's design pass. The home read like a technical report (module sizes, Brotli, font notes, the licence paragraph
twice). Now: 'Edit photos right in your browser.', Photo and Light each with one line and three features, Good to know
in three plain sentences, and one credit line linking PhotoCraft, LightCraft and the attribution page. The measured
detail stays on the app pages and in the docs. Hero buttons are one width on a phone.

* Bundles: copy PhotoCraft's snippets/ into app/ (the build's first-frame smoke test caught the 404s)

* Launch files: the systemd unit, the vhost and the bundles release

* vhost: serve /apps/ from /var/lib/openvibe-studio (bundles live outside the checkout)

* LAUNCH: the bundle root is /var/lib/openvibe-studio/apps

Changes

ddiff --git a/.github/workflows/bundle.yml b/.github/workflows/bundle.yml
nnew file mode 100644
iindex 0000000..9565730
---- /dev/null
++++ b/.github/workflows/bundle.yml
@@@ -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
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..1243aab
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -0,0 +1,23 @@
++name: ci
++on:
++ push:
++ branches: [main]
++ pull_request:
++ workflow_dispatch:
++permissions:
++ contents: read
++jobs:
++ test:
++ runs-on: ubuntu-24.04
++ steps:
++ - uses: actions/checkout@v4
++ - uses: actions/setup-node@v4
++ with:
++ node-version: 22.22.1
++ cache: npm
++ - run: npm ci --no-audit --no-fund
++ - name: syntax
++ run: find server test scripts public/js -name '*.js' -print0 | xargs -0 -n1 node --check
++ - run: npm test
++ security:
++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
ddiff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
nnew file mode 100644
iindex 0000000..ef00b4e
---- /dev/null
++++ b/ATTRIBUTION.md
@@@ -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.
ddiff --git a/LICENSE b/LICENSE
nnew file mode 100644
iindex 0000000..ac75a34
---- /dev/null
++++ b/LICENSE
@@@ -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.
ddiff --git a/NOTICE b/NOTICE
nnew file mode 100644
iindex 0000000..20064df
---- /dev/null
++++ b/NOTICE
@@@ -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.
ddiff --git a/README.md b/README.md
iindex ab842da..26dc37c 100644
---- a/README.md
++++ b/README.md
@@@ -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).
ddiff --git a/STATUS.json b/STATUS.json
nnew file mode 100644
iindex 0000000..6baaeae
---- /dev/null
++++ b/STATUS.json
@@@ -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"
++}
ddiff --git a/deploy/LAUNCH.md b/deploy/LAUNCH.md
nnew file mode 100644
iindex 0000000..23c39a0
---- /dev/null
++++ b/deploy/LAUNCH.md
@@@ -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.
ddiff --git a/deploy/nginx/openvibe.studio.conf b/deploy/nginx/openvibe.studio.conf
nnew file mode 100644
iindex 0000000..5d62f74
---- /dev/null
++++ b/deploy/nginx/openvibe.studio.conf
@@@ -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; }
++}
ddiff --git a/deploy/systemd/openvibe-studio.service b/deploy/systemd/openvibe-studio.service
nnew file mode 100644
iindex 0000000..439faea
---- /dev/null
++++ b/deploy/systemd/openvibe-studio.service
@@@ -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
ddiff --git a/docs/screenshots/home-1280-dark.png b/docs/screenshots/home-1280-dark.png
nnew file mode 100644
iindex 0000000..982e4ed
BBinary files /dev/null and b/docs/screenshots/home-1280-dark.png differ
ddiff --git a/docs/screenshots/home-1280-light.png b/docs/screenshots/home-1280-light.png
nnew file mode 100644
iindex 0000000..982e4ed
BBinary files /dev/null and b/docs/screenshots/home-1280-light.png differ
ddiff --git a/docs/screenshots/home-390-dark.png b/docs/screenshots/home-390-dark.png
nnew file mode 100644
iindex 0000000..a549598
BBinary files /dev/null and b/docs/screenshots/home-390-dark.png differ
ddiff --git a/docs/screenshots/home-390-light.png b/docs/screenshots/home-390-light.png
nnew file mode 100644
iindex 0000000..a549598
BBinary files /dev/null and b/docs/screenshots/home-390-light.png differ
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..914b787
---- /dev/null
++++ b/package-lock.json
@@@ -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"
++ }
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..b8f21f7
---- /dev/null
++++ b/package.json
@@@ -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"
++}
ddiff --git a/public/css/studio.css b/public/css/studio.css
nnew file mode 100644
iindex 0000000..7f8d0c0
---- /dev/null
++++ b/public/css/studio.css
@@@ -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; } }
ddiff --git a/public/js/drivers.js b/public/js/drivers.js
nnew file mode 100644
iindex 0000000..7ee8873
---- /dev/null
++++ b/public/js/drivers.js
@@@ -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 };
++});
ddiff --git a/public/js/studio.js b/public/js/studio.js
nnew file mode 100644
iindex 0000000..d0899ef
---- /dev/null
++++ b/public/js/studio.js
@@@ -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.');
++})();
ddiff --git a/public/openapi.json b/public/openapi.json
nnew file mode 100644
iindex 0000000..ffb54dc
---- /dev/null
++++ b/public/openapi.json
@@@ -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."
++ }
++ }
++ }
++ }
++ }
++}
ddiff --git a/scripts/build-bundles.sh b/scripts/build-bundles.sh
nnew file mode 100755
iindex 0000000..0586c85
---- /dev/null
++++ b/scripts/build-bundles.sh
@@@ -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"
ddiff --git a/scripts/fetch-bundles.sh b/scripts/fetch-bundles.sh
nnew file mode 100755
iindex 0000000..aea8270
---- /dev/null
++++ b/scripts/fetch-bundles.sh
@@@ -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"
ddiff --git a/scripts/smoke.js b/scripts/smoke.js
nnew file mode 100644
iindex 0000000..53f23f3
---- /dev/null
++++ b/scripts/smoke.js
@@@ -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);
++});
ddiff --git a/server/adapters/contract.js b/server/adapters/contract.js
nnew file mode 100644
iindex 0000000..064dc71
---- /dev/null
++++ b/server/adapters/contract.js
@@@ -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 };
ddiff --git a/server/adapters/index.js b/server/adapters/index.js
nnew file mode 100644
iindex 0000000..156d4bf
---- /dev/null
++++ b/server/adapters/index.js
@@@ -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 };
ddiff --git a/server/adapters/light.js b/server/adapters/light.js
nnew file mode 100644
iindex 0000000..8d8f9ac
---- /dev/null
++++ b/server/adapters/light.js
@@@ -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);
ddiff --git a/server/adapters/photo.js b/server/adapters/photo.js
nnew file mode 100644
iindex 0000000..6f96f9b
---- /dev/null
++++ b/server/adapters/photo.js
@@@ -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);
ddiff --git a/server/api.js b/server/api.js
nnew file mode 100644
iindex 0000000..5411285
---- /dev/null
++++ b/server/api.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..42465eb
---- /dev/null
++++ b/server/app.js
@@@ -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 };
ddiff --git a/server/bundle-files.js b/server/bundle-files.js
nnew file mode 100644
iindex 0000000..576b3f0
---- /dev/null
++++ b/server/bundle-files.js
@@@ -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 };
ddiff --git a/server/bundles.js b/server/bundles.js
nnew file mode 100644
iindex 0000000..a7347af
---- /dev/null
++++ b/server/bundles.js
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..610443d
---- /dev/null
++++ b/server/config.js
@@@ -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 };
ddiff --git a/server/html.js b/server/html.js
nnew file mode 100644
iindex 0000000..8d2f757
---- /dev/null
++++ b/server/html.js
@@@ -0,0 +1,3 @@
++'use strict';
++const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
++module.exports = { esc };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..9264090
---- /dev/null
++++ b/server/index.js
@@@ -0,0 +1,4 @@
++'use strict';
++const { createApp } = require('./app');
++const { app, config } = createApp();
++app.listen(config.port, config.host, () => console.log(`[Studio] listening on ${config.host}:${config.port} (drill ${config.drillPort})`));
ddiff --git a/server/layout.js b/server/layout.js
nnew file mode 100644
iindex 0000000..35da402
---- /dev/null
++++ b/server/layout.js
@@@ -0,0 +1,17 @@
++'use strict';
++const shell = require('openvibe-shared/shell');
++const serve = require('openvibe-shared/serve');
++const 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 };
ddiff --git a/server/openvibe/media.js b/server/openvibe/media.js
nnew file mode 100644
iindex 0000000..ff00b8e
---- /dev/null
++++ b/server/openvibe/media.js
@@@ -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 };
ddiff --git a/server/openvibe/pics.js b/server/openvibe/pics.js
nnew file mode 100644
iindex 0000000..ce2ecc0
---- /dev/null
++++ b/server/openvibe/pics.js
@@@ -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 };
ddiff --git a/server/pages.js b/server/pages.js
nnew file mode 100644
iindex 0000000..dfb7a12
---- /dev/null
++++ b/server/pages.js
@@@ -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 };
ddiff --git a/server/product.js b/server/product.js
nnew file mode 100644
iindex 0000000..43082ef
---- /dev/null
++++ b/server/product.js
@@@ -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 };
ddiff --git a/test/adapters.test.js b/test/adapters.test.js
nnew file mode 100644
iindex 0000000..4e7433b
---- /dev/null
++++ b/test/adapters.test.js
@@@ -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');
++ }
++});
ddiff --git a/test/media.test.js b/test/media.test.js
nnew file mode 100644
iindex 0000000..a69d45e
---- /dev/null
++++ b/test/media.test.js
@@@ -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);
++});
ddiff --git a/test/studio.test.js b/test/studio.test.js
nnew file mode 100644
iindex 0000000..dfc9def
---- /dev/null
++++ b/test/studio.test.js
@@@ -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);
++});