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)

OP OpenVibers committed 22 days ago · 39671454ad · 10 files, +377 −5

Changes

ddiff --git a/README.md b/README.md
iindex 26290b2..9c887c9 100644
---- a/README.md
++++ b/README.md
@@@ -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 on
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
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.
| [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)
ddiff --git a/STATUS.json b/STATUS.json
iindex a8e6af8..3f79c60 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/examples/space-deploy/.env.example b/examples/space-deploy/.env.example
nnew file mode 100644
iindex 0000000..87faad8
---- /dev/null
++++ b/examples/space-deploy/.env.example
@@@ -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
ddiff --git a/examples/space-deploy/README.md b/examples/space-deploy/README.md
nnew file mode 100644
iindex 0000000..6907093
---- /dev/null
++++ b/examples/space-deploy/README.md
@@@ -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.
ddiff --git a/examples/space-deploy/deploy.js b/examples/space-deploy/deploy.js
nnew file mode 100644
iindex 0000000..7897429
---- /dev/null
++++ b/examples/space-deploy/deploy.js
@@@ -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);
++ });
++}
ddiff --git a/examples/space-deploy/github-workflow.yml b/examples/space-deploy/github-workflow.yml
nnew file mode 100644
iindex 0000000..ac18e55
---- /dev/null
++++ b/examples/space-deploy/github-workflow.yml
@@@ -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
ddiff --git a/examples/space-deploy/package.json b/examples/space-deploy/package.json
nnew file mode 100644
iindex 0000000..5588925
---- /dev/null
++++ b/examples/space-deploy/package.json
@@@ -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"
++ }
++}
ddiff --git a/examples/space-deploy/test/smoke.test.js b/examples/space-deploy/test/smoke.test.js
nnew file mode 100644
iindex 0000000..978c5c4
---- /dev/null
++++ b/examples/space-deploy/test/smoke.test.js
@@@ -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); });
ddiff --git a/package-lock.json b/package-lock.json
iindex cc9bd80..642bf5f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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
ddiff --git a/test/structure.test.js b/test/structure.test.js
iindex a38c7c1..814bc30 100644
---- a/test/structure.test.js
++++ b/test/structure.test.js
@@@ -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 = [];