openvibers/openvibe.examples Commits
examples/space-deploy: deploy a static site to OpenVibe.Space from CI (app token, live or preview, rollback, a GitHub Actions workflow) (#31)
Changes
@@@ -3,7 +3,7 @@> Executable public integration examples for the OpenVibe platform: the SDK, scoped credentials> and public APIs, nothing else.--**Status:** alpha, 0.3.0 (roadmap Wave 20; the `create-openvibe-app` scaffolder, WS-N task 6). Twelve examples, each with a smoke test that CI runs on++**Status:** alpha, 0.3.0 (roadmap Wave 20; the `create-openvibe-app` scaffolder, WS-N task 6). Thirteen examples, each with a smoke test that CI runs onNode 22.22.1 against `openvibe-sdk/testing`'s mock platform (no network). `npm run e2e` runs threeof them (Media, Events pull, a Tools job) against the real platform with your own sandbox app; itis not part of CI or `npm test`, and it was not run against production as part of this release.@@@ -36,6 +36,7 @@ Each example is small, runs on its own and proves one thing with a smoke test.| [chat-bot](examples/chat-bot) | A bot on Chat's WebSocket protocol that stays in its one configured room and always identifies as a bot | Chat `/ws/chat` protocol (no SDK client: Chat is first-party) || [mod-manifest](examples/mod-manifest) | A `mods.mod-manifest@1` validated against the schema, the capability catalog and compatibility ranges | `openvibe-contracts`, `openvibe-sdk/registry` || [go-live](examples/go-live) | Go live on OpenVibe.Live from code: a Live API token finds or creates a WHIP slot and reads its WHIP URL, FFmpeg 8+ publishes (test pattern, a file or a camera), the session is named once media flows and ended when FFmpeg stops; the stream key is never printed | `openvibe-sdk/live` (`slots`, `streams`, `channels.url`), `/core`, FFmpeg's WHIP muxer |++| [space-deploy](examples/space-deploy) | Deploy a static site to OpenVibe.Space from CI with an app token (space.deploy.create, deployer role): the built folder as one deploy, live on main or a preview for a pull request, a refusal that changes nothing, rollback; a ready GitHub Actions workflow | `openvibe-sdk/space` (`deploys.uploadFiles`, `rollback`), `/auth` || [zone-s3](examples/zone-s3) | OpenVibe.Zone's private buckets over S3 with the official AWS SDK v3: put, get, a paged list, a presigned share link (at most 7 days) and delete; path-style, us-east-1, SigV4, an access key scoped to the zone | `@aws-sdk/client-s3`, `@aws-sdk/s3-request-presigner` || [ai-chat](examples/ai-chat) | The official `openai` npm client on `https://ai.openvibe.services/v1` with a Network app token: plain and streamed Chat Completions, `x_openvibe` run info, the project's own provider by secret name | `openai` (`baseURL`, `apiKey` as a token function), `openvibe-sdk/auth` |@@@ -292,7 +293,7 @@ test/public-surface.test.js, test/structure.test.js## Owns- the nine charter examples: vanilla browser app, Node server app, webhook consumer, event-- subscriber, Media uploader, Chat bot, tool/job example, mod manifest example, OAuth app example; plus the AI chat example (the openai client on AI's OpenAI-compatible endpoint) the go-live example (Live over WHIP from code) and the zone-s3 example (Zone with the AWS SDK v3)++ subscriber, Media uploader, Chat bot, tool/job example, mod manifest example, OAuth app example; plus the AI chat example (the openai client on AI's OpenAI-compatible endpoint) the go-live example (Live over WHIP from code), the zone-s3 example (Zone with the AWS SDK v3) and the space-deploy example (Space deploys from CI)- the on-demand end-to-end run (`npm run e2e`) and the sandbox-app setup script- the developer path check (`npm run developer-path`, and `developer-path:mock` in CI)@@@ -9,7 +9,7 @@"deployed": false,"deployment": "none: a repository with CI, not a runtime and not a published package","features": [-- "twelve copyable examples: browser app, Node server app, Media uploader, event subscriber, webhook consumer, tool job, OAuth app, chat bot, mod manifest, AI chat (the official openai client on ai.openvibe.services/v1, plain, streamed and the project's own provider), go live (a Live API token sets up a WHIP slot; FFmpeg 8+ publishes; the session is named and ended), Zone over S3 (the official AWS SDK v3: put, get, paged list, presigned share link, delete)",++ "thirteen copyable examples: browser app, Node server app, Media uploader, event subscriber, webhook consumer, tool job, OAuth app, chat bot, mod manifest, AI chat (the official openai client on ai.openvibe.services/v1, plain, streamed and the project's own provider), go live (a Live API token sets up a WHIP slot; FFmpeg 8+ publishes; the session is named and ended), Zone over S3 (the official AWS SDK v3: put, get, paged list, presigned share link, delete), Space deploys from CI (an app token, live or preview, rollback, a GitHub Actions workflow)","smoke tests against openvibe-sdk/testing's mock platform, offline","create-openvibe-app scaffolder","npm run e2e against the real platform (on demand)",nnew file mode 100644@@@ -0,0 +1,11 @@++# A developer app of the site's project (openvibe.services → project → Apps), added to the Space project as a++# deployer (openvibe.space → project → Members → app:<client id>). In CI these are repository secrets.++OV_CLIENT_ID=++OV_CLIENT_SECRET=++# The site to deploy to: its id (sit_…) is on the site's page in the Space dashboard.++OV_SPACE_SITE=++# Optional:++# OV_DIR=dist # the built folder; it must have an index.html at its top++# OV_PREVIEW=1 # a preview deploy (pull requests) instead of going live++# OV_NETWORK_URL=https://openvibe.network++# OV_SPACE_URL=https://openvibe.spacennew file mode 100644@@@ -0,0 +1,77 @@++# Deploy to Space from CI++++Ship a static site to [OpenVibe.Space](https://openvibe.space) from your build pipeline. Every push++to `main` goes live and every pull request gets a preview. A rollback is one command.++++```bash++npm run build && node --env-file=.env deploy.js++# Uploading 42 file(s), 318.4 KB, from dist++# Live: site sit_01J… now serves deploy dpl_01J…++++OV_PREVIEW=1 node --env-file=.env deploy.js # a preview, never live++node --env-file=.env deploy.js --rollback # back to the previous deploy++```++++[`github-workflow.yml`](github-workflow.yml) is a ready GitHub Actions workflow: copy it to++`.github/workflows/deploy-to-space.yml` with `deploy.js`.++++## What it proves++++- **An app token, not a person's:**++ - a developer app's client credentials, for audience `openvibe.space`, scoped to++ `space.deploy.create` alone;++ - the app is a member of the site's project with the **deployer** role, so it can deploy and roll++ back, and nothing more;++ - the SDK caches the five-minute token and refreshes it.++- **The built folder becomes one deploy:**++ - `space.deploys.uploadFiles()` sends every file with its path and type;++ - dotfiles (`.env`, `.git`) stay behind, and an `index.html` at the top is required;++ - Space checks the whole upload before anything changes.++- **Live or preview:**++ - live (`activate`) switches the site to the new deploy in one atomic step;++ - a preview (`preview`) is never activated; it is the site's preview until it expires.++- **A refused upload changes nothing.** A full quota or a file Space won't serve comes back as++ Space's problem+json code (`quota.exceeded`, `upload.busy`, …), and the site keeps serving what++ it served.++- **Rollback** (`space.deploys.rollback`) goes back to the previous deploy, or a named `dpl_…`,++ just as atomically.++++For thousands of files or a big site, `space.deploys.uploadArchive()` takes a tar.gz in one body.++++## Files++++| File | What |++|---|---|++| `deploy.js` | `loadConfig()`, `createSpace()`, `collectFiles()`, `deploy()`, `rollback()`, CLI |++| `github-workflow.yml` | the GitHub Actions workflow: live on `main`, preview on pull requests |++| `test/smoke.test.js` | token, upload, preview, refusal and rollback against a stand-in for Network and Space |++++## Run the smoke test++++```bash++npm test++```++++The test hands the SDK a `fetch` that answers like Network's `/oauth/token` and Space's deploy++routes (`server/http/api.js` on OpenVibe.Space's main). It checks:++- the token's audience and scope, and that the token is cached;++- the form's file names and types, with dotfiles left out;++- the `activate` and `preview` flags;++- that a `quota.exceeded` refusal reaches the caller;++- both rollback forms.++++Nothing reaches the network.++++## Run it against the real platform++++1. At [openvibe.space](https://openvibe.space), create a project and a site. The site's page shows++ its id (`sit_…`) and address.++2. At [openvibe.services](https://openvibe.services), create a developer app in the same project++ and copy its client id and secret.++3. Back in Space, open **project → Members** and add `app:<client id>` as a **deployer**.++4. Locally: `cp .env.example .env`, fill it in, and run `npm run build && npm start`.++5. In CI, store `OV_CLIENT_ID` and `OV_CLIENT_SECRET` as repository secrets and `OV_SPACE_SITE` as++ a variable.++++The site's address and its deploy history are on its page in the Space dashboard. Reading them from++code needs `space.site.manage`, which a deploy job doesn't need.nnew file mode 100644@@@ -0,0 +1,111 @@++'use strict';++/**++ * Deploy a static site to OpenVibe.Space from CI: an app's client-credentials token (audience openvibe.space, scope++ * space.deploy.create) uploads a built folder as a new deploy, and Space switches the site to it in one atomic step,++ * or keeps it as the site's preview for a pull request. A refused upload changes nothing: the site keeps serving its++ * current deploy. `--rollback` goes back to the previous deploy (or a named one) just as atomically.++ *++ * node --env-file=.env deploy.js # upload ./dist and make it live++ * OV_PREVIEW=1 node --env-file=.env deploy.js # a preview deploy (pull requests)++ * node --env-file=.env deploy.js --rollback # back to the previous deploy++ *++ * The app must be a member of the site's project with the deployer role (Space → project → Members → add app:<id>).++ * github-workflow.yml next to this file runs it on every push to main and as a preview for pull requests.++ */++const fs = require('node:fs');++const path = require('node:path');++const { createClient } = require('openvibe-sdk/core');++const { createServiceTokenClient } = require('openvibe-sdk/auth');++const { createSpaceClient } = require('openvibe-sdk/space');++++const SITE_RE = /^sit_[0-9A-HJKMNP-TV-Z]{26}$/;++const MAX_FILES = 5000;++++function loadConfig(env = process.env) {++ const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET', 'OV_SPACE_SITE'].filter((k) => !env[k]);++ if (missing.length) throw new Error(`missing environment variables: ${missing.join(', ')}`);++ if (!SITE_RE.test(env.OV_SPACE_SITE)) throw new Error('OV_SPACE_SITE is a site id (sit_…), shown on the site\'s page in the Space dashboard');++ return {++ network: env.OV_NETWORK_URL || 'https://openvibe.network',++ spaceUrl: String(env.OV_SPACE_URL || 'https://openvibe.space').replace(/\/+$/, ''),++ clientId: env.OV_CLIENT_ID,++ clientSecret: env.OV_CLIENT_SECRET,++ site: env.OV_SPACE_SITE,++ dir: env.OV_DIR || 'dist',++ preview: env.OV_PREVIEW === '1' || env.OV_PREVIEW === 'true',++ };++}++++/** Space's client on an app token that the SDK caches and refreshes (Network tokens last five minutes). */++function createSpace(config, { fetch } = {}) {++ const tokens = createServiceTokenClient({++ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch,++ audience: 'openvibe.space', scope: 'space.deploy.create',++ });++ return createSpaceClient(createClient({ tokenProvider: tokens, baseUrls: { space: config.spaceUrl }, ...(fetch ? { fetch } : {}) }));++}++++const TYPES = {++ '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8',++ '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp',++ '.gif': 'image/gif', '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml', '.wasm': 'application/wasm',++};++++/** Every file under dir as { path, data, type }, paths with forward slashes; dotfiles (.git, .env) stay behind. */++function collectFiles(dir) {++ const root = path.resolve(dir);++ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error(`${dir} is not a folder: build the site first`);++ const files = [];++ (function walk(d) {++ for (const e of fs.readdirSync(d, { withFileTypes: true })) {++ if (e.name.startsWith('.')) continue;++ const p = path.join(d, e.name);++ if (e.isDirectory()) walk(p);++ else if (e.isFile()) {++ const rel = path.relative(root, p).split(path.sep).join('/');++ files.push({ path: rel, data: fs.readFileSync(p), type: TYPES[path.extname(e.name).toLowerCase()] || 'application/octet-stream' });++ if (files.length > MAX_FILES) throw new Error(`more than ${MAX_FILES} files in ${dir}: upload a tar.gz with space.deploys.uploadArchive instead`);++ }++ }++ })(root);++ if (!files.some((f) => f.path === 'index.html')) throw new Error(`${dir} has no index.html at its top`);++ return files;++}++++/** Upload the folder → { deploy, activated, preview }. Live unless config.preview. */++async function deploy(space, config, { log = console.log } = {}) {++ const files = collectFiles(config.dir);++ const bytes = files.reduce((n, f) => n + f.data.length, 0);++ log(`Uploading ${files.length} file(s), ${(bytes / 1024).toFixed(1)} KB, from ${config.dir}${config.preview ? ' as a preview' : ''}`);++ // The token holds space.deploy.create only: reading the site itself (its address) needs space.site.manage, which++ // a deploy job has no use for. The address is on the site's page in the Space dashboard.++ const out = await space.deploys.uploadFiles(config.site, files, config.preview ? { preview: true } : { activate: true });++ if (out.preview) log(`Preview deploy ${out.deploy.id} is ready until ${out.preview.expires_at}; open it from the site's page in the Space dashboard`);++ else log(`Live: site ${config.site} now serves deploy ${out.deploy.id}`);++ return out;++}++++/** Back to an earlier deploy (the previous one, or deployId) → { active_deploy_id, previous_deploy_id, changed }. */++async function rollback(space, config, deployId, { log = console.log } = {}) {++ if (deployId && !/^dpl_[0-9A-HJKMNP-TV-Z]{26}$/.test(deployId)) throw new Error('--rollback takes a deploy id (dpl_…), or nothing for the previous deploy');++ const r = await space.deploys.rollback(config.site, deployId ? { deploy_id: deployId } : {});++ log(r.changed ? `Rolled back: site ${config.site} serves deploy ${r.active_deploy_id} (was ${r.previous_deploy_id})` : `Site ${config.site} already serves deploy ${r.active_deploy_id}`);++ return r;++}++++module.exports = { loadConfig, createSpace, collectFiles, deploy, rollback };++++if (require.main === module) {++ (async () => {++ const config = loadConfig();++ const space = createSpace(config);++ const i = process.argv.indexOf('--rollback');++ if (i >= 0) await rollback(space, config, process.argv[i + 1]);++ else await deploy(space, config);++ })().catch((err) => {++ // Space answers problem+json with a stable code (quota.exceeded, site.not_found, upload.busy, …) and, for a++ // refused upload, the reasons in its log.++ console.error(err.code ? `${err.code}: ${err.message}` : err.message);++ process.exit(1);++ });++}nnew file mode 100644@@@ -0,0 +1,31 @@++# Copy to .github/workflows/deploy-to-space.yml in your site's repository, with deploy.js and a package.json that++# depends on openvibe-sdk. Repository settings → Secrets and variables → Actions: OV_CLIENT_ID and OV_CLIENT_SECRET++# (secrets), OV_SPACE_SITE (a variable). Every push to main goes live; every pull request gets a preview.++name: Deploy to OpenVibe.Space++on:++ push:++ branches: [main]++ pull_request:++permissions:++ contents: read++concurrency:++ group: space-${{ github.ref }}++ cancel-in-progress: true++jobs:++ deploy:++ runs-on: ubuntu-latest++ steps:++ - uses: actions/checkout@v4++ - uses: actions/setup-node@v4++ with:++ node-version: 22++ - run: npm ci++ - run: npm run build # your build; it writes the site to dist/++ - name: Deploy++ env:++ OV_CLIENT_ID: ${{ secrets.OV_CLIENT_ID }}++ OV_CLIENT_SECRET: ${{ secrets.OV_CLIENT_SECRET }}++ OV_SPACE_SITE: ${{ vars.OV_SPACE_SITE }}++ OV_DIR: dist++ OV_PREVIEW: ${{ github.event_name == 'pull_request' && '1' || '' }}++ run: node deploy.jsnnew file mode 100644@@@ -0,0 +1,18 @@++{++ "name": "openvibe-example-space-deploy",++ "version": "0.1.0",++ "private": true,++ "description": "Deploy a static site to OpenVibe.Space from CI with an app token: live on main, a preview for pull requests, rollback.",++ "license": "MIT",++ "main": "deploy.js",++ "scripts": {++ "start": "node --env-file-if-exists=.env deploy.js",++ "test": "node test/smoke.test.js"++ },++ "engines": {++ "node": ">=22"++ },++ "dependencies": {++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0"++ }++}nnew file mode 100644@@@ -0,0 +1,108 @@++'use strict';++/**++ * Smoke test: deploy.js against a stand-in for Network's /oauth/token and Space's deploy routes (the shapes++ * server/http/api.js answers): one app token for openvibe.space with space.deploy.create, the built folder sent as++ * files with their paths and types (dotfiles left behind), live or as a preview, a refusal passed on with Space's++ * code, and a rollback. No network.++ */++const assert = require('node:assert/strict');++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++const { loadConfig, createSpace, collectFiles, deploy, rollback } = require('../deploy');++++globalThis.fetch = () => { throw new Error('network access in a smoke test'); };++++const SITE = 'sit_01JABCDEFGHJKMNPQRSTVWXYZ0';++const DPL = 'dpl_01JABCDEFGHJKMNPQRSTVWXYZ1';++const PREV = 'dpl_01JABCDEFGHJKMNPQRSTVWXYZ2';++const calls = [];++let refuse = false;++const json = (status, o, type = 'application/json') => new Response(JSON.stringify(o), { status, headers: { 'content-type': type } });++++async function fakeFetch(url, init = {}) {++ const u = new URL(String(url));++ const method = init.method || 'GET';++ const headers = new Headers(init.headers);++ if (u.href === 'https://openvibe.network/oauth/token') {++ const form = new URLSearchParams(String(init.body));++ calls.push({ kind: 'token', audience: form.get('audience'), scope: form.get('scope'), grant: form.get('grant_type') });++ return json(200, { access_token: 'tok-1', token_type: 'Bearer', expires_in: 300, scope: 'space.deploy.create' });++ }++ assert.equal(u.origin, 'https://openvibe.space');++ assert.equal(headers.get('authorization'), 'Bearer tok-1');++ if (method === 'POST' && u.pathname === `/api/v1/sites/${SITE}/deploys`) {++ const form = await new Request(u, { method, headers, body: init.body }).formData();++ const files = form.getAll('files').map((f) => ({ name: f.name, type: f.type, size: f.size }));++ calls.push({ kind: 'deploy', query: Object.fromEntries(u.searchParams), files });++ if (refuse) return json(413, { type: 'about:blank', title: 'Quota exceeded', status: 413, code: 'quota.exceeded', detail: 'this deploy would take the project past its 1 GB', deploy_id: DPL, log: ['error: quota.exceeded'] }, 'application/problem+json');++ const preview = u.searchParams.get('preview') === '1';++ return json(201, { deploy: { id: DPL, status: 'ready', files: files.length }, log: [], activated: preview ? null : { changed: true }, preview: preview ? { deploy_id: DPL, expires_at: '2026-10-29T00:00:00.000Z' } : null });++ }++ if (method === 'POST' && u.pathname === `/api/v1/sites/${SITE}/rollback`) {++ const body = JSON.parse(init.body || '{}');++ calls.push({ kind: 'rollback', body });++ return json(200, { active_deploy_id: body.deploy_id || PREV, previous_deploy_id: DPL, changed: true });++ }++ throw new Error(`unexpected ${method} ${u.pathname}`);++}++++(async () => {++ // A built site in a temporary folder, with a dotfile that must stay behind.++ const dist = fs.mkdtempSync(path.join(os.tmpdir(), 'space-deploy-'));++ fs.mkdirSync(path.join(dist, 'css'));++ fs.writeFileSync(path.join(dist, 'index.html'), '<!doctype html><title>Hi</title><link rel="stylesheet" href="css/site.css">');++ fs.writeFileSync(path.join(dist, 'css', 'site.css'), 'body{margin:0}');++ fs.writeFileSync(path.join(dist, '.env'), 'SECRET=never-uploaded');++ try {++ assert.throws(() => loadConfig({}), /missing environment variables: OV_CLIENT_ID, OV_CLIENT_SECRET, OV_SPACE_SITE/);++ assert.throws(() => loadConfig({ OV_CLIENT_ID: 'app_1', OV_CLIENT_SECRET: 's', OV_SPACE_SITE: 'my-site' }), /sit_…/);++ const config = loadConfig({ OV_CLIENT_ID: 'app_01TEST', OV_CLIENT_SECRET: 'test-secret-not-real', OV_SPACE_SITE: SITE, OV_DIR: dist });++ assert.deepEqual([config.dir, config.preview, config.spaceUrl], [dist, false, 'https://openvibe.space']);++++ // The folder: paths with forward slashes and types; dotfiles stay behind; an index.html is required.++ assert.deepEqual(collectFiles(dist).map((f) => [f.path, f.type]).sort(), [['css/site.css', 'text/css; charset=utf-8'], ['index.html', 'text/html; charset=utf-8']]);++ assert.throws(() => collectFiles(path.join(dist, 'css')), /no index\.html/);++ assert.throws(() => collectFiles(path.join(dist, 'missing')), /not a folder: build the site first/);++++ const space = createSpace(config, { fetch: fakeFetch });++++ // Live: one token (openvibe.space, space.deploy.create), the files as a form, activate=1.++ const lines = [];++ const live = await deploy(space, config, { log: (l) => lines.push(l) });++ assert.equal(live.deploy.id, DPL);++ const token = calls.find((c) => c.kind === 'token');++ assert.deepEqual([token.audience, token.scope, token.grant], ['openvibe.space', 'space.deploy.create', 'client_credentials']);++ const up = calls.find((c) => c.kind === 'deploy');++ assert.deepEqual(up.query, { activate: '1' });++ assert.deepEqual(up.files.map((f) => f.name).sort(), ['css/site.css', 'index.html']);++ assert.equal(up.files.find((f) => f.name === 'index.html').type, 'text/html; charset=utf-8');++ assert.deepEqual(lines, [`Uploading 2 file(s), ${((84 + 14) / 1024).toFixed(1)} KB, from ${dist}`, `Live: site ${SITE} now serves deploy ${DPL}`]);++++ // A preview (pull requests): preview=1, never activated.++ calls.length = 0;++ const pv = await deploy(space, { ...config, preview: true }, { log: () => {} });++ assert.deepEqual(calls.find((c) => c.kind === 'deploy').query, { preview: '1' });++ assert.equal(pv.preview.expires_at, '2026-10-29T00:00:00.000Z');++ assert.equal(calls.filter((c) => c.kind === 'token').length, 0, 'the token is cached between calls');++++ // A refused upload: Space's code reaches the caller; the site keeps its current deploy.++ refuse = true;++ await assert.rejects(deploy(space, config, { log: () => {} }), (err) => err.code === 'quota.exceeded' && err.status === 413);++ refuse = false;++++ // Rollback: the previous deploy, or a named one.++ const back = [];++ const r = await rollback(space, config, undefined, { log: (l) => back.push(l) });++ assert.equal(r.active_deploy_id, PREV);++ assert.deepEqual(calls.at(-1).body, {});++ await rollback(space, config, PREV, { log: () => {} });++ assert.deepEqual(calls.at(-1).body, { deploy_id: PREV });++ await assert.rejects(rollback(space, config, 'yesterday', { log: () => {} }), /deploy id \(dpl_…\)/);++ assert.deepEqual(back, [`Rolled back: site ${SITE} serves deploy ${PREV} (was ${DPL})`]);++++ console.log('space-deploy smoke: app token for openvibe.space, the folder as files (no dotfiles), live and preview deploys, a refusal, rollback');++ } finally {++ fs.rmSync(dist, { recursive: true, force: true });++ }++})().catch((err) => { console.error(err); process.exit(1); });@@@ -122,6 +122,17 @@"node": ">=22"}},++ "examples/space-deploy": {++ "name": "openvibe-example-space-deploy",++ "version": "0.1.0",++ "license": "MIT",++ "dependencies": {++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0"++ },++ "engines": {++ "node": ">=22"++ }++ },"examples/tool-job": {"name": "openvibe-example-tool-job","version": "0.2.0",@@@ -730,6 +741,10 @@"resolved": "examples/oauth-app","link": true},++ "node_modules/openvibe-example-space-deploy": {++ "resolved": "examples/space-deploy",++ "link": true++ },"node_modules/openvibe-example-tool-job": {"resolved": "examples/tool-job","link": true@@@ -11,13 +11,13 @@ const fs = require('node:fs');const path = require('node:path');const ROOT = path.join(__dirname, '..');--const EXPECTED = ['ai-chat', 'browser-app', 'chat-bot', 'event-subscriber', 'go-live', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'tool-job', 'webhook-consumer', 'zone-s3'];++const EXPECTED = ['ai-chat', 'browser-app', 'chat-bot', 'event-subscriber', 'go-live', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'space-deploy', 'tool-job', 'webhook-consumer', 'zone-s3'];// The pin every example must share: the repository's own (the pin bot moves them all at once).const ROOT_PKG = require(path.join(ROOT, 'package.json'));const SDK = (ROOT_PKG.dependencies || {})['openvibe-sdk'] || (ROOT_PKG.devDependencies || {})['openvibe-sdk'];const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();--assert.deepEqual(dirs, EXPECTED, 'the nine charter examples, ai-chat, go-live and zone-s3');++assert.deepEqual(dirs, EXPECTED, 'the nine charter examples, ai-chat, go-live, space-deploy and zone-s3');function sources(dir) {const out = [];
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
> Executable public integration examples for the OpenVibe platform: the SDK, scoped credentials
> Executable public integration examples for the OpenVibe platform: the SDK, scoped credentials
> and public APIs, nothing else.
> and public APIs, nothing else.
-**Status:** alpha, 0.3.0 (roadmap Wave 20; the `create-openvibe-app` scaffolder, WS-N task 6). Twelve examples, each with a smoke test that CI runs on
+**Status:** alpha, 0.3.0 (roadmap Wave 20; the `create-openvibe-app` scaffolder, WS-N task 6). Thirteen examples, each with a smoke test that CI runs on
Node 22.22.1 against `openvibe-sdk/testing`'s mock platform (no network). `npm run e2e` runs three
Node 22.22.1 against `openvibe-sdk/testing`'s mock platform (no network). `npm run e2e` runs three
of them (Media, Events pull, a Tools job) against the real platform with your own sandbox app; it
of them (Media, Events pull, a Tools job) against the real platform with your own sandbox app; it
is not part of CI or `npm test`, and it was not run against production as part of this release.
is not part of CI or `npm test`, and it was not run against production as part of this release.
@@ -36,6 +36,7 @@ Each example is small, runs on its own and proves one thing with a smoke test.
@@ -36,6 +36,7 @@ Each example is small, runs on its own and proves one thing with a smoke test.
| [chat-bot](examples/chat-bot) | A bot on Chat's WebSocket protocol that stays in its one configured room and always identifies as a bot | Chat `/ws/chat` protocol (no SDK client: Chat is first-party) |
| [chat-bot](examples/chat-bot) | A bot on Chat's WebSocket protocol that stays in its one configured room and always identifies as a bot | Chat `/ws/chat` protocol (no SDK client: Chat is first-party) |
| [mod-manifest](examples/mod-manifest) | A `mods.mod-manifest@1` validated against the schema, the capability catalog and compatibility ranges | `openvibe-contracts`, `openvibe-sdk/registry` |
| [mod-manifest](examples/mod-manifest) | A `mods.mod-manifest@1` validated against the schema, the capability catalog and compatibility ranges | `openvibe-contracts`, `openvibe-sdk/registry` |
| [go-live](examples/go-live) | Go live on OpenVibe.Live from code: a Live API token finds or creates a WHIP slot and reads its WHIP URL, FFmpeg 8+ publishes (test pattern, a file or a camera), the session is named once media flows and ended when FFmpeg stops; the stream key is never printed | `openvibe-sdk/live` (`slots`, `streams`, `channels.url`), `/core`, FFmpeg's WHIP muxer |
| [go-live](examples/go-live) | Go live on OpenVibe.Live from code: a Live API token finds or creates a WHIP slot and reads its WHIP URL, FFmpeg 8+ publishes (test pattern, a file or a camera), the session is named once media flows and ended when FFmpeg stops; the stream key is never printed | `openvibe-sdk/live` (`slots`, `streams`, `channels.url`), `/core`, FFmpeg's WHIP muxer |
+| [space-deploy](examples/space-deploy) | Deploy a static site to OpenVibe.Space from CI with an app token (space.deploy.create, deployer role): the built folder as one deploy, live on main or a preview for a pull request, a refusal that changes nothing, rollback; a ready GitHub Actions workflow | `openvibe-sdk/space` (`deploys.uploadFiles`, `rollback`), `/auth` |
| [zone-s3](examples/zone-s3) | OpenVibe.Zone's private buckets over S3 with the official AWS SDK v3: put, get, a paged list, a presigned share link (at most 7 days) and delete; path-style, us-east-1, SigV4, an access key scoped to the zone | `@aws-sdk/client-s3`, `@aws-sdk/s3-request-presigner` |
| [zone-s3](examples/zone-s3) | OpenVibe.Zone's private buckets over S3 with the official AWS SDK v3: put, get, a paged list, a presigned share link (at most 7 days) and delete; path-style, us-east-1, SigV4, an access key scoped to the zone | `@aws-sdk/client-s3`, `@aws-sdk/s3-request-presigner` |
| [ai-chat](examples/ai-chat) | The official `openai` npm client on `https://ai.openvibe.services/v1` with a Network app token: plain and streamed Chat Completions, `x_openvibe` run info, the project's own provider by secret name | `openai` (`baseURL`, `apiKey` as a token function), `openvibe-sdk/auth` |
| [ai-chat](examples/ai-chat) | The official `openai` npm client on `https://ai.openvibe.services/v1` with a Network app token: plain and streamed Chat Completions, `x_openvibe` run info, the project's own provider by secret name | `openai` (`baseURL`, `apiKey` as a token function), `openvibe-sdk/auth` |
@@ -292,7 +293,7 @@ test/public-surface.test.js, test/structure.test.js
@@ -292,7 +293,7 @@ test/public-surface.test.js, test/structure.test.js
## Owns
## Owns
- the nine charter examples: vanilla browser app, Node server app, webhook consumer, event
- the nine charter examples: vanilla browser app, Node server app, webhook consumer, event
- subscriber, Media uploader, Chat bot, tool/job example, mod manifest example, OAuth app example; plus the AI chat example (the openai client on AI's OpenAI-compatible endpoint) the go-live example (Live over WHIP from code) and the zone-s3 example (Zone with the AWS SDK v3)
+ subscriber, Media uploader, Chat bot, tool/job example, mod manifest example, OAuth app example; plus the AI chat example (the openai client on AI's OpenAI-compatible endpoint) the go-live example (Live over WHIP from code), the zone-s3 example (Zone with the AWS SDK v3) and the space-deploy example (Space deploys from CI)
- the on-demand end-to-end run (`npm run e2e`) and the sandbox-app setup script
- the on-demand end-to-end run (`npm run e2e`) and the sandbox-app setup script
- the developer path check (`npm run developer-path`, and `developer-path:mock` in CI)
- the developer path check (`npm run developer-path`, and `developer-path:mock` in CI)
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
"deployed": false,
"deployed": false,
"deployment": "none: a repository with CI, not a runtime and not a published package",
"deployment": "none: a repository with CI, not a runtime and not a published package",
"features": [
"features": [
- "twelve copyable examples: browser app, Node server app, Media uploader, event subscriber, webhook consumer, tool job, OAuth app, chat bot, mod manifest, AI chat (the official openai client on ai.openvibe.services/v1, plain, streamed and the project's own provider), go live (a Live API token sets up a WHIP slot; FFmpeg 8+ publishes; the session is named and ended), Zone over S3 (the official AWS SDK v3: put, get, paged list, presigned share link, delete)",
+ "thirteen copyable examples: browser app, Node server app, Media uploader, event subscriber, webhook consumer, tool job, OAuth app, chat bot, mod manifest, AI chat (the official openai client on ai.openvibe.services/v1, plain, streamed and the project's own provider), go live (a Live API token sets up a WHIP slot; FFmpeg 8+ publishes; the session is named and ended), Zone over S3 (the official AWS SDK v3: put, get, paged list, presigned share link, delete), Space deploys from CI (an app token, live or preview, rollback, a GitHub Actions workflow)",
"smoke tests against openvibe-sdk/testing's mock platform, offline",
"smoke tests against openvibe-sdk/testing's mock platform, offline",
"create-openvibe-app scaffolder",
"create-openvibe-app scaffolder",
"npm run e2e against the real platform (on demand)",
"npm run e2e against the real platform (on demand)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+# A developer app of the site's project (openvibe.services → project → Apps), added to the Space project as a
+# deployer (openvibe.space → project → Members → app:<client id>). In CI these are repository secrets.
+OV_CLIENT_ID=
+OV_CLIENT_SECRET=
+# The site to deploy to: its id (sit_…) is on the site's page in the Space dashboard.
+OV_SPACE_SITE=
+# Optional:
+# OV_DIR=dist # the built folder; it must have an index.html at its top
+# OV_PREVIEW=1 # a preview deploy (pull requests) instead of going live
+# OV_NETWORK_URL=https://openvibe.network
+# OV_SPACE_URL=https://openvibe.space
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+# Deploy to Space from CI
+
+Ship a static site to [OpenVibe.Space](https://openvibe.space) from your build pipeline. Every push
+to `main` goes live and every pull request gets a preview. A rollback is one command.
+
+```bash
+npm run build && node --env-file=.env deploy.js
+# Uploading 42 file(s), 318.4 KB, from dist
+# Live: site sit_01J… now serves deploy dpl_01J…
+
+OV_PREVIEW=1 node --env-file=.env deploy.js # a preview, never live
+node --env-file=.env deploy.js --rollback # back to the previous deploy
+```
+
+[`github-workflow.yml`](github-workflow.yml) is a ready GitHub Actions workflow: copy it to
+`.github/workflows/deploy-to-space.yml` with `deploy.js`.
+
+## What it proves
+
+- **An app token, not a person's:**
+ - a developer app's client credentials, for audience `openvibe.space`, scoped to
+ `space.deploy.create` alone;
+ - the app is a member of the site's project with the **deployer** role, so it can deploy and roll
+ back, and nothing more;
+ - the SDK caches the five-minute token and refreshes it.
+- **The built folder becomes one deploy:**
+ - `space.deploys.uploadFiles()` sends every file with its path and type;
+ - dotfiles (`.env`, `.git`) stay behind, and an `index.html` at the top is required;
+ - Space checks the whole upload before anything changes.
+- **Live or preview:**
+ - live (`activate`) switches the site to the new deploy in one atomic step;
+ - a preview (`preview`) is never activated; it is the site's preview until it expires.
+- **A refused upload changes nothing.** A full quota or a file Space won't serve comes back as
+ Space's problem+json code (`quota.exceeded`, `upload.busy`, …), and the site keeps serving what
+ it served.
+- **Rollback** (`space.deploys.rollback`) goes back to the previous deploy, or a named `dpl_…`,
+ just as atomically.
+
+For thousands of files or a big site, `space.deploys.uploadArchive()` takes a tar.gz in one body.
+
+## Files
+
+| File | What |
+|---|---|
+| `deploy.js` | `loadConfig()`, `createSpace()`, `collectFiles()`, `deploy()`, `rollback()`, CLI |
+| `github-workflow.yml` | the GitHub Actions workflow: live on `main`, preview on pull requests |
+| `test/smoke.test.js` | token, upload, preview, refusal and rollback against a stand-in for Network and Space |
+
+## Run the smoke test
+
+```bash
+npm test
+```
+
+The test hands the SDK a `fetch` that answers like Network's `/oauth/token` and Space's deploy
+routes (`server/http/api.js` on OpenVibe.Space's main). It checks:
+- the token's audience and scope, and that the token is cached;
+- the form's file names and types, with dotfiles left out;
+- the `activate` and `preview` flags;
+- that a `quota.exceeded` refusal reaches the caller;
+- both rollback forms.
+
+Nothing reaches the network.
+
+## Run it against the real platform
+
+1. At [openvibe.space](https://openvibe.space), create a project and a site. The site's page shows
+ its id (`sit_…`) and address.
+2. At [openvibe.services](https://openvibe.services), create a developer app in the same project
+ and copy its client id and secret.
+3. Back in Space, open **project → Members** and add `app:<client id>` as a **deployer**.
+4. Locally: `cp .env.example .env`, fill it in, and run `npm run build && npm start`.
+5. In CI, store `OV_CLIENT_ID` and `OV_CLIENT_SECRET` as repository secrets and `OV_SPACE_SITE` as
+ a variable.
+
+The site's address and its deploy history are on its page in the Space dashboard. Reading them from
+code needs `space.site.manage`, which a deploy job doesn't need.
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+'use strict';
+/**
+ * Deploy a static site to OpenVibe.Space from CI: an app's client-credentials token (audience openvibe.space, scope
+ * space.deploy.create) uploads a built folder as a new deploy, and Space switches the site to it in one atomic step,
+ * or keeps it as the site's preview for a pull request. A refused upload changes nothing: the site keeps serving its
+ * current deploy. `--rollback` goes back to the previous deploy (or a named one) just as atomically.
+ *
+ * node --env-file=.env deploy.js # upload ./dist and make it live
+ * OV_PREVIEW=1 node --env-file=.env deploy.js # a preview deploy (pull requests)
+ * node --env-file=.env deploy.js --rollback # back to the previous deploy
+ *
+ * The app must be a member of the site's project with the deployer role (Space → project → Members → add app:<id>).
+ * github-workflow.yml next to this file runs it on every push to main and as a preview for pull requests.
+ */
+const fs = require('node:fs');
+const path = require('node:path');
+const { createClient } = require('openvibe-sdk/core');
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+const { createSpaceClient } = require('openvibe-sdk/space');
+
+const SITE_RE = /^sit_[0-9A-HJKMNP-TV-Z]{26}$/;
+const MAX_FILES = 5000;
+
+function loadConfig(env = process.env) {
+ const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET', 'OV_SPACE_SITE'].filter((k) => !env[k]);
+ if (missing.length) throw new Error(`missing environment variables: ${missing.join(', ')}`);
+ if (!SITE_RE.test(env.OV_SPACE_SITE)) throw new Error('OV_SPACE_SITE is a site id (sit_…), shown on the site\'s page in the Space dashboard');
+ return {
+ network: env.OV_NETWORK_URL || 'https://openvibe.network',
+ spaceUrl: String(env.OV_SPACE_URL || 'https://openvibe.space').replace(/\/+$/, ''),
+ clientId: env.OV_CLIENT_ID,
+ clientSecret: env.OV_CLIENT_SECRET,
+ site: env.OV_SPACE_SITE,
+ dir: env.OV_DIR || 'dist',
+ preview: env.OV_PREVIEW === '1' || env.OV_PREVIEW === 'true',
+ };
+}
+
+/** Space's client on an app token that the SDK caches and refreshes (Network tokens last five minutes). */
+function createSpace(config, { fetch } = {}) {
+ const tokens = createServiceTokenClient({
+ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch,
+ audience: 'openvibe.space', scope: 'space.deploy.create',
+ });
+ return createSpaceClient(createClient({ tokenProvider: tokens, baseUrls: { space: config.spaceUrl }, ...(fetch ? { fetch } : {}) }));
+}
+
+const TYPES = {
+ '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8',
+ '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp',
+ '.gif': 'image/gif', '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml', '.wasm': 'application/wasm',
+};
+
+/** Every file under dir as { path, data, type }, paths with forward slashes; dotfiles (.git, .env) stay behind. */
+function collectFiles(dir) {
+ const root = path.resolve(dir);
+ if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) throw new Error(`${dir} is not a folder: build the site first`);
+ const files = [];
+ (function walk(d) {
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
+ if (e.name.startsWith('.')) continue;
+ const p = path.join(d, e.name);
+ if (e.isDirectory()) walk(p);
+ else if (e.isFile()) {
+ const rel = path.relative(root, p).split(path.sep).join('/');
+ files.push({ path: rel, data: fs.readFileSync(p), type: TYPES[path.extname(e.name).toLowerCase()] || 'application/octet-stream' });
+ if (files.length > MAX_FILES) throw new Error(`more than ${MAX_FILES} files in ${dir}: upload a tar.gz with space.deploys.uploadArchive instead`);
+ }
+ }
+ })(root);
+ if (!files.some((f) => f.path === 'index.html')) throw new Error(`${dir} has no index.html at its top`);
+ return files;
+}
+
+/** Upload the folder → { deploy, activated, preview }. Live unless config.preview. */
+async function deploy(space, config, { log = console.log } = {}) {
+ const files = collectFiles(config.dir);
+ const bytes = files.reduce((n, f) => n + f.data.length, 0);
+ log(`Uploading ${files.length} file(s), ${(bytes / 1024).toFixed(1)} KB, from ${config.dir}${config.preview ? ' as a preview' : ''}`);
+ // The token holds space.deploy.create only: reading the site itself (its address) needs space.site.manage, which
+ // a deploy job has no use for. The address is on the site's page in the Space dashboard.
+ const out = await space.deploys.uploadFiles(config.site, files, config.preview ? { preview: true } : { activate: true });
+ if (out.preview) log(`Preview deploy ${out.deploy.id} is ready until ${out.preview.expires_at}; open it from the site's page in the Space dashboard`);
+ else log(`Live: site ${config.site} now serves deploy ${out.deploy.id}`);
+ return out;
+}
+
+/** Back to an earlier deploy (the previous one, or deployId) → { active_deploy_id, previous_deploy_id, changed }. */
+async function rollback(space, config, deployId, { log = console.log } = {}) {
+ if (deployId && !/^dpl_[0-9A-HJKMNP-TV-Z]{26}$/.test(deployId)) throw new Error('--rollback takes a deploy id (dpl_…), or nothing for the previous deploy');
+ const r = await space.deploys.rollback(config.site, deployId ? { deploy_id: deployId } : {});
+ log(r.changed ? `Rolled back: site ${config.site} serves deploy ${r.active_deploy_id} (was ${r.previous_deploy_id})` : `Site ${config.site} already serves deploy ${r.active_deploy_id}`);
+ return r;
+}
+
+module.exports = { loadConfig, createSpace, collectFiles, deploy, rollback };
+
+if (require.main === module) {
+ (async () => {
+ const config = loadConfig();
+ const space = createSpace(config);
+ const i = process.argv.indexOf('--rollback');
+ if (i >= 0) await rollback(space, config, process.argv[i + 1]);
+ else await deploy(space, config);
+ })().catch((err) => {
+ // Space answers problem+json with a stable code (quota.exceeded, site.not_found, upload.busy, …) and, for a
+ // refused upload, the reasons in its log.
+ console.error(err.code ? `${err.code}: ${err.message}` : err.message);
+ process.exit(1);
+ });
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+# Copy to .github/workflows/deploy-to-space.yml in your site's repository, with deploy.js and a package.json that
+# depends on openvibe-sdk. Repository settings → Secrets and variables → Actions: OV_CLIENT_ID and OV_CLIENT_SECRET
+# (secrets), OV_SPACE_SITE (a variable). Every push to main goes live; every pull request gets a preview.
+name: Deploy to OpenVibe.Space
+on:
+ push:
+ branches: [main]
+ pull_request:
+permissions:
+ contents: read
+concurrency:
+ group: space-${{ github.ref }}
+ cancel-in-progress: true
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ - run: npm ci
+ - run: npm run build # your build; it writes the site to dist/
+ - name: Deploy
+ env:
+ OV_CLIENT_ID: ${{ secrets.OV_CLIENT_ID }}
+ OV_CLIENT_SECRET: ${{ secrets.OV_CLIENT_SECRET }}
+ OV_SPACE_SITE: ${{ vars.OV_SPACE_SITE }}
+ OV_DIR: dist
+ OV_PREVIEW: ${{ github.event_name == 'pull_request' && '1' || '' }}
+ run: node deploy.js
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+{
+ "name": "openvibe-example-space-deploy",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Deploy a static site to OpenVibe.Space from CI with an app token: live on main, a preview for pull requests, rollback.",
+ "license": "MIT",
+ "main": "deploy.js",
+ "scripts": {
+ "start": "node --env-file-if-exists=.env deploy.js",
+ "test": "node test/smoke.test.js"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "dependencies": {
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+'use strict';
+/**
+ * Smoke test: deploy.js against a stand-in for Network's /oauth/token and Space's deploy routes (the shapes
+ * server/http/api.js answers): one app token for openvibe.space with space.deploy.create, the built folder sent as
+ * files with their paths and types (dotfiles left behind), live or as a preview, a refusal passed on with Space's
+ * code, and a rollback. No network.
+ */
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { loadConfig, createSpace, collectFiles, deploy, rollback } = require('../deploy');
+
+globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
+
+const SITE = 'sit_01JABCDEFGHJKMNPQRSTVWXYZ0';
+const DPL = 'dpl_01JABCDEFGHJKMNPQRSTVWXYZ1';
+const PREV = 'dpl_01JABCDEFGHJKMNPQRSTVWXYZ2';
+const calls = [];
+let refuse = false;
+const json = (status, o, type = 'application/json') => new Response(JSON.stringify(o), { status, headers: { 'content-type': type } });
+
+async function fakeFetch(url, init = {}) {
+ const u = new URL(String(url));
+ const method = init.method || 'GET';
+ const headers = new Headers(init.headers);
+ if (u.href === 'https://openvibe.network/oauth/token') {
+ const form = new URLSearchParams(String(init.body));
+ calls.push({ kind: 'token', audience: form.get('audience'), scope: form.get('scope'), grant: form.get('grant_type') });
+ return json(200, { access_token: 'tok-1', token_type: 'Bearer', expires_in: 300, scope: 'space.deploy.create' });
+ }
+ assert.equal(u.origin, 'https://openvibe.space');
+ assert.equal(headers.get('authorization'), 'Bearer tok-1');
+ if (method === 'POST' && u.pathname === `/api/v1/sites/${SITE}/deploys`) {
+ const form = await new Request(u, { method, headers, body: init.body }).formData();
+ const files = form.getAll('files').map((f) => ({ name: f.name, type: f.type, size: f.size }));
+ calls.push({ kind: 'deploy', query: Object.fromEntries(u.searchParams), files });
+ if (refuse) return json(413, { type: 'about:blank', title: 'Quota exceeded', status: 413, code: 'quota.exceeded', detail: 'this deploy would take the project past its 1 GB', deploy_id: DPL, log: ['error: quota.exceeded'] }, 'application/problem+json');
+ const preview = u.searchParams.get('preview') === '1';
+ return json(201, { deploy: { id: DPL, status: 'ready', files: files.length }, log: [], activated: preview ? null : { changed: true }, preview: preview ? { deploy_id: DPL, expires_at: '2026-10-29T00:00:00.000Z' } : null });
+ }
+ if (method === 'POST' && u.pathname === `/api/v1/sites/${SITE}/rollback`) {
+ const body = JSON.parse(init.body || '{}');
+ calls.push({ kind: 'rollback', body });
+ return json(200, { active_deploy_id: body.deploy_id || PREV, previous_deploy_id: DPL, changed: true });
+ }
+ throw new Error(`unexpected ${method} ${u.pathname}`);
+}
+
+(async () => {
+ // A built site in a temporary folder, with a dotfile that must stay behind.
+ const dist = fs.mkdtempSync(path.join(os.tmpdir(), 'space-deploy-'));
+ fs.mkdirSync(path.join(dist, 'css'));
+ fs.writeFileSync(path.join(dist, 'index.html'), '<!doctype html><title>Hi</title><link rel="stylesheet" href="css/site.css">');
+ fs.writeFileSync(path.join(dist, 'css', 'site.css'), 'body{margin:0}');
+ fs.writeFileSync(path.join(dist, '.env'), 'SECRET=never-uploaded');
+ try {
+ assert.throws(() => loadConfig({}), /missing environment variables: OV_CLIENT_ID, OV_CLIENT_SECRET, OV_SPACE_SITE/);
+ assert.throws(() => loadConfig({ OV_CLIENT_ID: 'app_1', OV_CLIENT_SECRET: 's', OV_SPACE_SITE: 'my-site' }), /sit_…/);
+ const config = loadConfig({ OV_CLIENT_ID: 'app_01TEST', OV_CLIENT_SECRET: 'test-secret-not-real', OV_SPACE_SITE: SITE, OV_DIR: dist });
+ assert.deepEqual([config.dir, config.preview, config.spaceUrl], [dist, false, 'https://openvibe.space']);
+
+ // The folder: paths with forward slashes and types; dotfiles stay behind; an index.html is required.
+ assert.deepEqual(collectFiles(dist).map((f) => [f.path, f.type]).sort(), [['css/site.css', 'text/css; charset=utf-8'], ['index.html', 'text/html; charset=utf-8']]);
+ assert.throws(() => collectFiles(path.join(dist, 'css')), /no index\.html/);
+ assert.throws(() => collectFiles(path.join(dist, 'missing')), /not a folder: build the site first/);
+
+ const space = createSpace(config, { fetch: fakeFetch });
+
+ // Live: one token (openvibe.space, space.deploy.create), the files as a form, activate=1.
+ const lines = [];
+ const live = await deploy(space, config, { log: (l) => lines.push(l) });
+ assert.equal(live.deploy.id, DPL);
+ const token = calls.find((c) => c.kind === 'token');
+ assert.deepEqual([token.audience, token.scope, token.grant], ['openvibe.space', 'space.deploy.create', 'client_credentials']);
+ const up = calls.find((c) => c.kind === 'deploy');
+ assert.deepEqual(up.query, { activate: '1' });
+ assert.deepEqual(up.files.map((f) => f.name).sort(), ['css/site.css', 'index.html']);
+ assert.equal(up.files.find((f) => f.name === 'index.html').type, 'text/html; charset=utf-8');
+ assert.deepEqual(lines, [`Uploading 2 file(s), ${((84 + 14) / 1024).toFixed(1)} KB, from ${dist}`, `Live: site ${SITE} now serves deploy ${DPL}`]);
+
+ // A preview (pull requests): preview=1, never activated.
+ calls.length = 0;
+ const pv = await deploy(space, { ...config, preview: true }, { log: () => {} });
+ assert.deepEqual(calls.find((c) => c.kind === 'deploy').query, { preview: '1' });
+ assert.equal(pv.preview.expires_at, '2026-10-29T00:00:00.000Z');
+ assert.equal(calls.filter((c) => c.kind === 'token').length, 0, 'the token is cached between calls');
+
+ // A refused upload: Space's code reaches the caller; the site keeps its current deploy.
+ refuse = true;
+ await assert.rejects(deploy(space, config, { log: () => {} }), (err) => err.code === 'quota.exceeded' && err.status === 413);
+ refuse = false;
+
+ // Rollback: the previous deploy, or a named one.
+ const back = [];
+ const r = await rollback(space, config, undefined, { log: (l) => back.push(l) });
+ assert.equal(r.active_deploy_id, PREV);
+ assert.deepEqual(calls.at(-1).body, {});
+ await rollback(space, config, PREV, { log: () => {} });
+ assert.deepEqual(calls.at(-1).body, { deploy_id: PREV });
+ await assert.rejects(rollback(space, config, 'yesterday', { log: () => {} }), /deploy id \(dpl_…\)/);
+ assert.deepEqual(back, [`Rolled back: site ${SITE} serves deploy ${PREV} (was ${DPL})`]);
+
+ console.log('space-deploy smoke: app token for openvibe.space, the folder as files (no dotfiles), live and preview deploys, a refusal, rollback');
+ } finally {
+ fs.rmSync(dist, { recursive: true, force: true });
+ }
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -122,6 +122,17 @@
@@ -122,6 +122,17 @@
"node": ">=22"
"node": ">=22"
}
}
},
},
+ "examples/space-deploy": {
+ "name": "openvibe-example-space-deploy",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
"examples/tool-job": {
"examples/tool-job": {
"name": "openvibe-example-tool-job",
"name": "openvibe-example-tool-job",
"version": "0.2.0",
"version": "0.2.0",
@@ -730,6 +741,10 @@
@@ -730,6 +741,10 @@
"resolved": "examples/oauth-app",
"resolved": "examples/oauth-app",
"link": true
"link": true
},
},
+ "node_modules/openvibe-example-space-deploy": {
+ "resolved": "examples/space-deploy",
+ "link": true
+ },
"node_modules/openvibe-example-tool-job": {
"node_modules/openvibe-example-tool-job": {
"resolved": "examples/tool-job",
"resolved": "examples/tool-job",
"link": true
"link": true
@@ -11,13 +11,13 @@ const fs = require('node:fs');
@@ -11,13 +11,13 @@ const fs = require('node:fs');
const path = require('node:path');
const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
-const EXPECTED = ['ai-chat', 'browser-app', 'chat-bot', 'event-subscriber', 'go-live', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'tool-job', 'webhook-consumer', 'zone-s3'];
+const EXPECTED = ['ai-chat', 'browser-app', 'chat-bot', 'event-subscriber', 'go-live', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'space-deploy', 'tool-job', 'webhook-consumer', 'zone-s3'];
// The pin every example must share: the repository's own (the pin bot moves them all at once).
// The pin every example must share: the repository's own (the pin bot moves them all at once).
const ROOT_PKG = require(path.join(ROOT, 'package.json'));
const ROOT_PKG = require(path.join(ROOT, 'package.json'));
const SDK = (ROOT_PKG.dependencies || {})['openvibe-sdk'] || (ROOT_PKG.devDependencies || {})['openvibe-sdk'];
const SDK = (ROOT_PKG.dependencies || {})['openvibe-sdk'] || (ROOT_PKG.devDependencies || {})['openvibe-sdk'];
const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();
const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();
-assert.deepEqual(dirs, EXPECTED, 'the nine charter examples, ai-chat, go-live and zone-s3');
+assert.deepEqual(dirs, EXPECTED, 'the nine charter examples, ai-chat, go-live, space-deploy and zone-s3');
function sources(dir) {
function sources(dir) {
const out = [];
const out = [];