openvibers/openvibe.examples Commits
examples/zone-s3: OpenVibe.Zone with the official AWS SDK v3 (put, get, paged list, presigned share link, delete; path-style, us-east-1, SigV4) (#30)
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). Eleven 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). Twelve 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 |++| [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` |Each example folder has a README, a `.env.example`, its own `package.json` and a@@@ -291,7 +292,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) and the go-live example (Live over WHIP from code)++ 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)- 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": [-- "eleven 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)",++ "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)","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,7 @@++# An access key from the Zone console (openvibe.zone → your project → Access keys). Its secret is shown once.++OV_S3_ACCESS_KEY_ID=++OV_S3_SECRET_ACCESS_KEY=++# The zone (bucket) to use; the key must be allowed to read and write it.++OV_ZONE=++# Optional:++# OV_S3_ENDPOINT=https://s3.openvibe.mediannew file mode 100644@@@ -0,0 +1,79 @@++# Zone over S3 (AWS SDK v3)++++Use [OpenVibe.Zone](https://openvibe.zone)'s private buckets ("zones") from Node with the official++AWS SDK v3, as you would S3. Zone's S3 endpoint is **path-style, region `us-east-1`, Signature++Version 4**.++++```bash++node --env-file=.env zone.js++# put s3://assets/openvibe-example/hello-1729….txt++# list 1 object(s) under openvibe-example/++# share a 10-minute link reads the same bytes++# get the same bytes (62 bytes)++# delete s3://assets/openvibe-example/hello-1729….txt++++node --env-file=.env zone.js put ./report.pdf reports/2026-10.pdf++node --env-file=.env zone.js ls reports/++node --env-file=.env zone.js share reports/2026-10.pdf 86400++```++++## What it proves++++- **An `S3Client` for Zone** is three settings: `endpoint: 'https://s3.openvibe.media'`,++ `region: 'us-east-1'` and `forcePathStyle: true`. Everything else is the SDK's defaults, including++ its flexible checksums and aws-chunked uploads, which Zone accepts.++- **The everyday calls:**++ - `PutObjectCommand` with a content type;++ - `GetObjectCommand`;++ - `paginateListObjectsV2`, which follows pages;++ - a presigned GET link from `@aws-sdk/s3-request-presigner`, lasting 1 s to 7 days, which opens++ without credentials until it expires or its key is revoked;++ - `DeleteObjectCommand`.++- **The access key** is the only credential. It belongs to one project environment and may be++ limited to some zones and to read or write. Its secret is shown once, when it is issued, and it++ signs requests: it never travels, appears in a link or is printed.++- **Large files:** one request carries at most 256 MB. `@aws-sdk/lib-storage`'s `Upload` (or++ `aws s3 cp`, or rclone) sends bigger files in parts, under S3's rules (every part but the last at++ least 5 MiB).++- A zone's objects are OpenVibe.Media objects. Media's API++ (`/api/v2/:project_id/objects` with `namespace` set to the zone's id) reads the same files.++++## Files++++| File | What |++|---|---|++| `zone.js` | `loadConfig()`, `createS3()`, `put()`, `get()`, `list()`, `share()`, `remove()`, `roundTrip()`, CLI |++| `test/smoke.test.js` | the real AWS SDK v3 against a local stand-in for Zone's S3 endpoint |++++## Run the smoke test++++```bash++npm test++```++++The test runs the real `@aws-sdk/client-s3` and `@aws-sdk/s3-request-presigner` against a stand-in++for Zone's endpoint on 127.0.0.1. The stand-in handles path-style requests and decodes aws-chunked++bodies, as OpenVibe.Media's `server/s3/routes.js` does. It checks:++- the signature's scope (`<key id>/<date>/us-east-1/s3/aws4_request`);++- path-style addressing;++- the list paginator following continuation tokens;++- the share link's expiry and that it opens without credentials;++- `NoSuchKey` after a delete;++- that a key from elsewhere is refused;++- that the secret never appears on the wire or in output.++++## Run it against the real platform++++1. Sign in at [openvibe.zone](https://openvibe.zone), pick a project (or make one at++ openvibe.services) and create a zone, for example `assets`. You can also create zones with++ `openvibe-sdk/zone` (`zone.zones.create(projectId, { env, name, quota_bytes })`) from an app of++ the project.++2. Issue an access key that can read and write that zone, and copy its secret, which is shown once.++3. `cp .env.example .env`, fill in `OV_S3_ACCESS_KEY_ID`, `OV_S3_SECRET_ACCESS_KEY` and `OV_ZONE`,++ then `npm install && npm start`.++++The round trip writes one small object under `openvibe-example/` and deletes it again.++++The same settings work in other tools:++- the AWS CLI: `aws --endpoint-url https://s3.openvibe.media s3 ls s3://assets/`;++- rclone: `provider Other`, the endpoint above, `no_check_bucket true`.nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "name": "openvibe-example-zone-s3",++ "version": "0.1.0",++ "private": true,++ "description": "OpenVibe.Zone over S3 with the official AWS SDK v3: put, get, list, a presigned share link and delete in a private zone.",++ "license": "MIT",++ "main": "zone.js",++ "scripts": {++ "start": "node --env-file-if-exists=.env zone.js",++ "test": "node test/smoke.test.js"++ },++ "engines": {++ "node": ">=22"++ },++ "dependencies": {++ "@aws-sdk/client-s3": "3.1148.0",++ "@aws-sdk/s3-request-presigner": "3.1148.0"++ }++}nnew file mode 100644@@@ -0,0 +1,127 @@++'use strict';++/**++ * Smoke test: zone.js through the real AWS SDK v3 against a local stand-in for Zone's S3 endpoint (path-style++ * /<zone>/<key>, region us-east-1, Signature Version 4, aws-chunked bodies as OpenVibe.Media's server/s3/routes.js++ * reads them). It checks what the SDK sends (the signing region and service, path-style addressing, the access key++ * id, never the secret) and what comes back. The stand-in listens on 127.0.0.1 only; nothing reaches the network.++ */++const assert = require('node:assert/strict');++const http = require('node:http');++const { loadConfig, createS3, put, get, list, share, remove, roundTrip } = require('../zone');++++const ACCESS = 'ovk_TESTKEYNOTREAL';++const SECRET = 'test-secret-not-real-0123456789';++const objects = new Map(); // "<zone>/<key>" → { body, type }++const seen = [];++++/** aws-chunked (STREAMING-UNSIGNED-PAYLOAD-TRAILER or signed chunks): <hex size>[;…]\r\n<data>\r\n … 0\r\n<trailers>\r\n\r\n */++function dechunk(buf) {++ const out = [];++ let i = 0;++ for (;;) {++ const eol = buf.indexOf('\r\n', i);++ const size = parseInt(buf.subarray(i, eol).toString().split(';')[0], 16);++ if (!size) break;++ out.push(buf.subarray(eol + 2, eol + 2 + size));++ i = eol + 2 + size + 2;++ }++ return Buffer.concat(out);++}++++const xml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<');++const server = http.createServer(async (req, res) => {++ const chunks = [];++ for await (const c of req) chunks.push(c);++ let body = Buffer.concat(chunks);++ const u = new URL(req.url, 'http://x');++ const auth = String(req.headers.authorization || '');++ const presigned = u.searchParams.get('X-Amz-Algorithm') === 'AWS4-HMAC-SHA256';++ const cred = presigned ? u.searchParams.get('X-Amz-Credential') : (/Credential=([^,]+)/.exec(auth) || [])[1];++ seen.push({ method: req.method, path: u.pathname, cred, presigned, auth, sha: req.headers['x-amz-content-sha256'], query: u.search });++ // Signed by the access key, for us-east-1 and s3, as Zone checks; the secret never travels.++ if (!cred || !/^ovk_TESTKEYNOTREAL\/\d{8}\/us-east-1\/s3\/aws4_request$/.test(cred)) { res.writeHead(403); return res.end('<Error><Code>AccessDenied</Code></Error>'); }++ if (JSON.stringify(req.headers).includes(SECRET) || req.url.includes(SECRET)) { res.writeHead(400); return res.end('secret on the wire'); }++ const [, zone, ...rest] = decodeURIComponent(u.pathname).split('/');++ const key = rest.join('/');++ if (req.method === 'PUT' && key) {++ if (/STREAMING/.test(String(req.headers['x-amz-content-sha256'] || '')) || /aws-chunked/.test(String(req.headers['content-encoding'] || ''))) body = dechunk(body);++ objects.set(`${zone}/${key}`, { body, type: req.headers['content-type'] || 'application/octet-stream' });++ res.writeHead(200, { ETag: '"etag-1"' });++ return res.end();++ }++ if (req.method === 'GET' && key) {++ const o = objects.get(`${zone}/${key}`);++ if (!o) { res.writeHead(404, { 'Content-Type': 'application/xml' }); return res.end('<Error><Code>NoSuchKey</Code></Error>'); }++ res.writeHead(200, { 'Content-Type': o.type, 'Content-Length': o.body.length, ETag: '"etag-1"' });++ return res.end(o.body);++ }++ if (req.method === 'DELETE' && key) { objects.delete(`${zone}/${key}`); res.writeHead(204); return res.end(); }++ if (req.method === 'GET' && !key && u.searchParams.get('list-type') === '2') {++ const prefix = u.searchParams.get('prefix') || '';++ const all = [...objects.keys()].filter((k) => k.startsWith(`${zone}/${prefix}`)).map((k) => k.slice(zone.length + 1)).sort();++ // Two to a page here, so the SDK's paginator follows continuation tokens.++ const start = Number(u.searchParams.get('continuation-token') || 0);++ const page = all.slice(start, start + 2);++ const more = start + 2 < all.length;++ res.writeHead(200, { 'Content-Type': 'application/xml' });++ return res.end(`<?xml version="1.0" encoding="UTF-8"?><ListBucketResult><Name>${xml(zone)}</Name><Prefix>${xml(prefix)}</Prefix><KeyCount>${page.length}</KeyCount><MaxKeys>2</MaxKeys><IsTruncated>${more}</IsTruncated>${more ? `<NextContinuationToken>${start + 2}</NextContinuationToken>` : ''}${page.map((k) => `<Contents><Key>${xml(k)}</Key><Size>${objects.get(`${zone}/${k}`).body.length}</Size><LastModified>2026-10-22T00:00:00.000Z</LastModified><ETag>"etag-1"</ETag></Contents>`).join('')}</ListBucketResult>`);++ }++ res.writeHead(400); res.end();++});++++(async () => {++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const endpoint = `http://127.0.0.1:${server.address().port}`;++ try {++ assert.throws(() => loadConfig({}), /missing environment variables: OV_S3_ACCESS_KEY_ID, OV_S3_SECRET_ACCESS_KEY, OV_ZONE/);++ assert.throws(() => loadConfig({ OV_S3_ACCESS_KEY_ID: ACCESS, OV_S3_SECRET_ACCESS_KEY: SECRET, OV_ZONE: 'My_Bucket' }), /zone name/);++ assert.throws(() => loadConfig({ OV_S3_ACCESS_KEY_ID: ACCESS, OV_S3_SECRET_ACCESS_KEY: SECRET, OV_ZONE: 'a--b' }), /zone name/);++ assert.equal(loadConfig({ OV_S3_ACCESS_KEY_ID: ACCESS, OV_S3_SECRET_ACCESS_KEY: SECRET, OV_ZONE: 'assets' }).endpoint, 'https://s3.openvibe.media');++ const config = loadConfig({ OV_S3_ACCESS_KEY_ID: ACCESS, OV_S3_SECRET_ACCESS_KEY: SECRET, OV_ZONE: 'assets', OV_S3_ENDPOINT: endpoint });++ const s3 = createS3(config);++++ // Put and get: path-style, signed for us-east-1 by the access key; the bytes and type come back.++ await put(s3, 'assets', 'notes/a.txt', 'first');++ await put(s3, 'assets', 'notes/b.json', Buffer.from('{"b":2}'));++ await put(s3, 'assets', 'notes/c.txt', 'third');++ const putReq = seen.find((x) => x.method === 'PUT');++ assert.equal(putReq.path, '/assets/notes/a.txt', 'path-style /<zone>/<key>');++ assert.match(putReq.auth, /^AWS4-HMAC-SHA256 Credential=ovk_TESTKEYNOTREAL\/\d{8}\/us-east-1\/s3\/aws4_request, SignedHeaders=[^,]+, Signature=[0-9a-f]{64}$/);++ const a = await get(s3, 'assets', 'notes/a.txt');++ assert.deepEqual([a.bytes.toString(), a.contentType], ['first', 'text/plain; charset=utf-8']);++ assert.equal((await get(s3, 'assets', 'notes/b.json')).contentType, 'application/json');++++ // List follows pages (two to a page here).++ assert.deepEqual((await list(s3, 'assets', 'notes/')).map((o) => o.key), ['notes/a.txt', 'notes/b.json', 'notes/c.txt']);++ assert.ok(seen.filter((x) => x.query.includes('list-type=2')).length >= 2, 'the paginator asked for a second page');++++ // A share link: presigned GET, at most 7 days, opens without credentials.++ await assert.rejects(share(s3, 'assets', 'notes/a.txt', 8 * 24 * 3600), /1 s to 7 days/);++ const url = await share(s3, 'assets', 'notes/a.txt', 600);++ const link = new URL(url);++ assert.equal(link.pathname, '/assets/notes/a.txt');++ assert.equal(link.searchParams.get('X-Amz-Expires'), '600');++ assert.ok(!url.includes(SECRET), 'the secret is never in a link');++ assert.equal(await (await fetch(url)).text(), 'first');++++ // Delete, then a missing key is the SDK's NoSuchKey.++ await remove(s3, 'assets', 'notes/a.txt');++ await assert.rejects(get(s3, 'assets', 'notes/a.txt'), (err) => err.name === 'NoSuchKey');++++ // The CLI's round trip.++ const lines = [];++ const r = await roundTrip(s3, 'assets', { log: (l) => lines.push(l) });++ assert.equal(r.same, true);++ assert.ok(!objects.has(`assets/${r.key}`), 'the round trip cleans up');++ assert.deepEqual(lines.map((l) => l.split(/\s+/)[0]), ['put', 'list', 'share', 'get', 'delete']);++ assert.ok(lines.every((l) => !l.includes(SECRET)));++++ // A key from somewhere else is refused (the stand-in checks the credential scope like Zone).++ const other = createS3({ ...config, accessKeyId: 'ovk_OTHER' });++ await assert.rejects(get(other, 'assets', 'notes/b.json'), (err) => err.$metadata && err.$metadata.httpStatusCode === 403);++++ console.log('zone-s3 smoke: put, get, paged list, share link, delete and the round trip through the AWS SDK v3; SigV4 for us-east-1, path-style');++ } finally {++ server.close();++ }++})().catch((err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,112 @@++'use strict';++/**++ * OpenVibe.Zone over S3 with the official AWS SDK v3: private buckets ("zones") of an OpenVibe project, reached by any++ * S3 tool. A zone's objects are OpenVibe.Media objects, so the same files are readable through Media's API too.++ *++ * Zone's S3 endpoint is path-style, region us-east-1, Signature Version 4 (https://openvibe.zone/docs#s3). An access++ * key belongs to one project environment and may be limited to some zones and to read or write; its secret is shown++ * once, when it is issued in the Zone console. This file shows the everyday calls: put, get, list, a presigned link++ * to share, and delete.++ *++ * node --env-file=.env zone.js # the round trip: put, list, share, get, delete++ * node --env-file=.env zone.js put ./report.pdf reports/2026-10.pdf++ * node --env-file=.env zone.js ls reports/++ * node --env-file=.env zone.js share reports/2026-10.pdf 86400++ *++ * Larger files: one request carries at most 256 MB; @aws-sdk/lib-storage's Upload (or `aws s3 cp`, rclone) sends++ * bigger ones in parts, which Zone accepts with S3's rules.++ */++const fs = require('node:fs');++const path = require('node:path');++const { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, paginateListObjectsV2 } = require('@aws-sdk/client-s3');++const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');++++const MAX_SHARE_SECONDS = 7 * 24 * 3600; // Zone's limit for a presigned URL, as S3's++++function loadConfig(env = process.env) {++ const missing = ['OV_S3_ACCESS_KEY_ID', 'OV_S3_SECRET_ACCESS_KEY', 'OV_ZONE'].filter((k) => !env[k]);++ if (missing.length) throw new Error(`missing environment variables: ${missing.join(', ')}`);++ // Zone's own rule for names: 3 to 63 lowercase letters and digits, single hyphens between them.++ if (!/^(?=.{3,63}$)[a-z0-9]+(-[a-z0-9]+)*$/.test(env.OV_ZONE)) throw new Error('OV_ZONE is a zone name (3 to 63 lowercase letters, digits and single hyphens)');++ return {++ endpoint: String(env.OV_S3_ENDPOINT || 'https://s3.openvibe.media').replace(/\/+$/, ''),++ zone: env.OV_ZONE,++ accessKeyId: env.OV_S3_ACCESS_KEY_ID,++ secretAccessKey: env.OV_S3_SECRET_ACCESS_KEY,++ };++}++++/** An S3 client for Zone: its endpoint, path-style addressing, us-east-1. The SDK's own defaults do the rest. */++function createS3(config) {++ return new S3Client({++ endpoint: config.endpoint, region: 'us-east-1', forcePathStyle: true,++ credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey },++ });++}++++const TYPES = { '.txt': 'text/plain; charset=utf-8', '.json': 'application/json', '.html': 'text/html; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.pdf': 'application/pdf', '.mp4': 'video/mp4' };++const typeOf = (key) => TYPES[path.extname(key).toLowerCase()] || 'application/octet-stream';++++async function put(s3, zone, key, body, contentType = typeOf(key)) {++ const out = await s3.send(new PutObjectCommand({ Bucket: zone, Key: key, Body: body, ContentType: contentType }));++ return { key, etag: out.ETag };++}++++async function get(s3, zone, key) {++ const out = await s3.send(new GetObjectCommand({ Bucket: zone, Key: key }));++ return { bytes: Buffer.from(await out.Body.transformToByteArray()), contentType: out.ContentType };++}++++/** Every key under a prefix, page after page (Zone pages like S3: 1000 a page). */++async function list(s3, zone, prefix = '') {++ const keys = [];++ for await (const page of paginateListObjectsV2({ client: s3 }, { Bucket: zone, Prefix: prefix })) {++ for (const o of page.Contents || []) keys.push({ key: o.Key, size: o.Size, modified: o.LastModified });++ }++ return keys;++}++++/** A link anyone can open until it expires (at most 7 days), or until the key that signed it is revoked. */++async function share(s3, zone, key, seconds = 3600) {++ if (!(seconds > 0 && seconds <= MAX_SHARE_SECONDS)) throw new Error(`a share link lasts 1 s to 7 days (${MAX_SHARE_SECONDS} s)`);++ return await getSignedUrl(s3, new GetObjectCommand({ Bucket: zone, Key: key }), { expiresIn: seconds });++}++++async function remove(s3, zone, key) {++ await s3.send(new DeleteObjectCommand({ Bucket: zone, Key: key }));++}++++/** The round trip the CLI runs with no arguments. */++async function roundTrip(s3, zone, { log = console.log, fetchImpl = globalThis.fetch } = {}) {++ const key = `openvibe-example/hello-${Date.now()}.txt`;++ const text = `Hello from the zone-s3 example, ${new Date().toISOString()}\n`;++ await put(s3, zone, key, text);++ log(`put s3://${zone}/${key}`);++ const listed = await list(s3, zone, 'openvibe-example/');++ log(`list ${listed.length} object(s) under openvibe-example/`);++ const url = await share(s3, zone, key, 600);++ const viaLink = await (await fetchImpl(url)).text();++ log(`share a 10-minute link reads ${viaLink === text ? 'the same bytes' : 'something else'}`);++ const back = (await get(s3, zone, key)).bytes.toString('utf8');++ log(`get ${back === text ? 'the same bytes' : 'something else'} (${Buffer.byteLength(back)} bytes)`);++ await remove(s3, zone, key);++ log(`delete s3://${zone}/${key}`);++ return { key, same: back === text && viaLink === text, listed: listed.length };++}++++module.exports = { loadConfig, createS3, put, get, list, share, remove, roundTrip, MAX_SHARE_SECONDS };++++if (require.main === module) {++ (async () => {++ const config = loadConfig();++ const s3 = createS3(config);++ const [cmd, a, b] = process.argv.slice(2);++ if (!cmd) { const r = await roundTrip(s3, config.zone); process.exit(r.same ? 0 : 1); }++ if (cmd === 'put') console.log((await put(s3, config.zone, b || path.basename(a), fs.readFileSync(a))).etag);++ else if (cmd === 'get') process.stdout.write((await get(s3, config.zone, a)).bytes);++ else if (cmd === 'ls') for (const o of await list(s3, config.zone, a || '')) console.log(`${String(o.size).padStart(12)} ${o.key}`);++ else if (cmd === 'share') console.log(await share(s3, config.zone, a, b ? Number(b) : 3600));++ else if (cmd === 'rm') await remove(s3, config.zone, a);++ else throw new Error('usage: zone.js [put <file> [key] | get <key> | ls [prefix] | share <key> [seconds] | rm <key>]');++ })().catch((err) => { console.error(err.name && err.$metadata ? `${err.name}: ${err.message}` : err.message); process.exit(1); });++}@@@ -146,12 +146,430 @@"node": ">=22"}},++ "examples/zone-s3": {++ "name": "openvibe-example-zone-s3",++ "version": "0.1.0",++ "license": "MIT",++ "dependencies": {++ "@aws-sdk/client-s3": "3.1148.0",++ "@aws-sdk/s3-request-presigner": "3.1148.0"++ },++ "engines": {++ "node": ">=22"++ }++ },++ "node_modules/@aws-sdk/checksums": {++ "version": "3.1001.5",++ "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.5.tgz",++ "integrity": "sha512-6erto9nK2OZO/woEgmFIaPh5gChgdNd7nL6jgAf8TAXz3/QHC8Pan2TFe9GYp3IxOIKCCGWPylSjtOHKfNzQlw==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/core": "^3.978.5",++ "@aws-sdk/types": "^3.975.0",++ "@smithy/core": "^3.36.1",++ "@smithy/types": "^4.19.0",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/client-s3": {++ "version": "3.1148.0",++ "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1148.0.tgz",++ "integrity": "sha512-d2AcfJCZTi4w5zJZD7H/XeVRgJy1vOD5EfO1Y3pKAwfp8pWz2dyJ8Q3vOJFbRiTIQF7c4mB+aptNUyCE4i4kqw==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/checksums": "^3.1001.1",++ "@aws-sdk/core": "^3.978.1",++ "@aws-sdk/credential-provider-node": "^3.972.84",++ "@aws-sdk/middleware-sdk-s3": "^3.972.77",++ "@aws-sdk/signature-v4-multi-region": "^3.996.47",++ "@aws-sdk/types": "^3.974.6",++ "@smithy/core": "^3.35.0",++ "@smithy/fetch-http-handler": "^5.8.0",++ "@smithy/node-http-handler": "^4.12.1",++ "@smithy/types": "^4.19.0",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/core": {++ "version": "3.978.5",++ "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.5.tgz",++ "integrity": "sha512-ruI0AWqcrfL20XDS6SwZIJFPOBZ6N0ADa9nLzWlBhxk1aoUjbUX3JkTR9vEmk7o35XpAHi68ahZ4T6RE2U9XyA==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/types": "^3.975.0",++ "@aws-sdk/xml-builder": "^3.972.41",++ "@aws/lambda-invoke-store": "^0.3.0",++ "@smithy/core": "^3.36.1",++ "@smithy/signature-v4": "^5.7.4",++ "@smithy/types": "^4.19.0",++ "bowser": "^2.11.0",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/credential-provider-env": {++ "version": "3.972.76",++ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.76.tgz",++ "integrity": "sha512-YxmL1Ar7bV9ZkJvtv+lj+MF6le76NnZnbmgySyZuxpZaCv0/1lZ3KxKy6RIj1h9ui2hWnVjZxbmTMeO0y/NrQA==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/core": "^3.978.5",++ "@aws-sdk/types": "^3.975.0",++ "@smithy/core": "^3.36.1",++ "@smithy/types": "^4.19.0",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/credential-provider-http": {++ "version": "3.972.78",++ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.78.tgz",++ "integrity": "sha512-3VHknDRngoebsaqyT1iR75b5cBT2zfEtODK6yYmYltljkK1HEAQLs2FEd1IpWqAeOYdMGaG9wikngWLTufY7RA==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/core": "^3.978.5",++ "@aws-sdk/types": "^3.975.0",++ "@smithy/core": "^3.36.1",++ "@smithy/fetch-http-handler": "^5.8.0",++ "@smithy/node-http-handler": "^4.12.1",++ "@smithy/types": "^4.19.0",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/credential-provider-ini": {++ "version": "3.973.21",++ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.21.tgz",++ "integrity": "sha512-aLx9hbCX6mGAyv4ZZd8UagJ4RASHVJw6BFDSIyuKmLkqNL6H47EjBPK6qaJ4B5TDV6mc+vMa0SJFYyo4mkvVQg==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/core": "^3.978.5",++ "@aws-sdk/credential-provider-env": "^3.972.76",++ "@aws-sdk/credential-provider-http": "^3.972.78",++ "@aws-sdk/credential-provider-login": "^3.972.83",++ "@aws-sdk/credential-provider-process": "^3.972.76",++ "@aws-sdk/credential-provider-sso": "^3.973.20",++ "@aws-sdk/credential-provider-web-identity": "^3.972.82",++ "@aws-sdk/nested-clients": "^3.997.50",++ "@aws-sdk/types": "^3.975.0",++ "@smithy/core": "^3.36.1",++ "@smithy/credential-provider-imds": "^4.5.2",++ "@smithy/types": "^4.19.0",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/credential-provider-login": {++ "version": "3.972.83",++ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.83.tgz",++ "integrity": "sha512-9bJU6FjRacF4FK/QHf8Dyxhur7B1grRj8C1Yh4IkpoFW2exQflPdCv0VrbemLW9ZctLNle+AE7TETs9KNvuLYg==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/core": "^3.978.5",++ "@aws-sdk/nested-clients": "^3.997.50",++ "@aws-sdk/types": "^3.975.0",++ "@smithy/core": "^3.36.1",++ "@smithy/types": "^4.19.0",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/credential-provider-node": {++ "version": "3.972.88",++ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.88.tgz",++ "integrity": "sha512-AG0KID5qhQPzrFkh5b0Cws1Amytvzo0L0Xj6hMgJXEwD6ZXAqninDhSNAWJiN5zU4YVVZnuidnqwPwYJ04IamA==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/credential-provider-env": "^3.972.76",++ "@aws-sdk/credential-provider-http": "^3.972.78",++ "@aws-sdk/credential-provider-ini": "^3.973.21",++ "@aws-sdk/credential-provider-process": "^3.972.76",++ "@aws-sdk/credential-provider-sso": "^3.973.20",++ "@aws-sdk/credential-provider-web-identity": "^3.972.82",++ "@aws-sdk/types": "^3.975.0",++ "@smithy/core": "^3.36.1",++ "@smithy/credential-provider-imds": "^4.5.2",++ "@smithy/types": "^4.19.0",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/credential-provider-process": {++ "version": "3.972.76",++ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.76.tgz",++ "integrity": "sha512-pdhKFTXv1zPYhh92MTlp0jVbLWvUEV9v+rziuEkoskGGQ2iJskxHTO38nA8UAbT/N4IM3n8OZJ9rIY5IBO15/A==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/core": "^3.978.5",++ "@aws-sdk/types": "^3.975.0",++ "@smithy/core": "^3.36.1",++ "@smithy/types": "^4.19.0",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/credential-provider-sso": {++ "version": "3.973.20",++ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.20.tgz",++ "integrity": "sha512-DlxrAB3MnjNLx/da7b89W+3DyJQYso/q7Ns15MsST2IV2nkAYQOYTUCaffNSjgrpMP99Dm2mV2jxzcbyF94ZfA==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/core": "^3.978.5",++ "@aws-sdk/nested-clients": "^3.997.50",++ "@aws-sdk/token-providers": "3.1155.0",++ "@aws-sdk/types": "^3.975.0",++ "@smithy/core": "^3.36.1",++ "@smithy/types": "^4.19.0",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/credential-provider-web-identity": {++ "version": "3.972.82",++ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.82.tgz",++ "integrity": "sha512-LUo46IqPTJ4xKBsrLuoaxiv/D2GTBCcFDMD1y3jKIqPR/p7Rw14RJFZ0iRWftqZtyLPQX9JMGnSi3mBu4y/pNA==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/core": "^3.978.5",++ "@aws-sdk/nested-clients": "^3.997.50",++ "@aws-sdk/types": "^3.975.0",++ "@smithy/core": "^3.36.1",++ "@smithy/types": "^4.19.0",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/middleware-sdk-s3": {++ "version": "3.972.81",++ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.81.tgz",++ "integrity": "sha512-jfanv1pV0sJFZw6/DPiNjvK7B478god0zOum5yIWo+/+1Gy+CSXWpGBWx+3nBJyDvjcu/2kUMr4ADUp9BHH34Q==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/core": "^3.978.5",++ "@aws-sdk/signature-v4-multi-region": "^3.996.49",++ "@aws-sdk/types": "^3.975.0",++ "@smithy/core": "^3.36.1",++ "@smithy/types": "^4.19.0",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/nested-clients": {++ "version": "3.997.50",++ "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.50.tgz",++ "integrity": "sha512-nKb8qu4QsnpZTq/6JIXc72mzuzfh7BK4sUQkpQznPec2z4W7Ob2xYkcM4i5e1yGTBnbJVeFv/0sf3jcz85hclA==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/core": "^3.978.5",++ "@aws-sdk/signature-v4-multi-region": "^3.996.49",++ "@aws-sdk/types": "^3.975.0",++ "@smithy/core": "^3.36.1",++ "@smithy/fetch-http-handler": "^5.8.0",++ "@smithy/node-http-handler": "^4.12.1",++ "@smithy/types": "^4.19.0",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/s3-request-presigner": {++ "version": "3.1148.0",++ "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1148.0.tgz",++ "integrity": "sha512-k9UM9y4fSUl6QzO2tluO2/MzQRghVhF3kbQ2DXAcZAEECvarGa8zxI4oTEq2VHthhNyYuBuOmLvGD8EH/1vtTQ==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/core": "^3.978.1",++ "@aws-sdk/signature-v4-multi-region": "^3.996.47",++ "@aws-sdk/types": "^3.974.6",++ "@smithy/core": "^3.35.0",++ "@smithy/types": "^4.19.0",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/signature-v4-multi-region": {++ "version": "3.996.49",++ "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.49.tgz",++ "integrity": "sha512-nvuAZNRrRRVCiARRKsYlQ0VnXiEQiq7vXPH+UA+LSo+kN3Djl9eo/V2r0udkw85PnsNshmv2Em1clld5M3j67w==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/types": "^3.975.0",++ "@smithy/signature-v4": "^5.7.4",++ "@smithy/types": "^4.19.0",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/token-providers": {++ "version": "3.1155.0",++ "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1155.0.tgz",++ "integrity": "sha512-iIcQnYLhWuq9qOLQbIWFrxF3PF0svVeZvHnUS4DUnO4zUFmFHIyGyjqrntXG+e/5PUgfavV0pYfDIKHxTzN2LA==",++ "license": "Apache-2.0",++ "dependencies": {++ "@aws-sdk/core": "^3.978.5",++ "@aws-sdk/nested-clients": "^3.997.50",++ "@aws-sdk/types": "^3.975.0",++ "@smithy/core": "^3.36.1",++ "@smithy/types": "^4.19.0",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/types": {++ "version": "3.975.0",++ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.975.0.tgz",++ "integrity": "sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw==",++ "license": "Apache-2.0",++ "dependencies": {++ "@smithy/types": "^4.19.0",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws-sdk/xml-builder": {++ "version": "3.972.41",++ "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz",++ "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==",++ "license": "Apache-2.0",++ "dependencies": {++ "@smithy/types": "^4.19.0",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=20.0.0"++ }++ },++ "node_modules/@aws/lambda-invoke-store": {++ "version": "0.3.0",++ "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",++ "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=18.0.0"++ }++ },"node_modules/@electric-sql/pglite": {"version": "0.5.8","resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz","integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==","license": "Apache-2.0"},++ "node_modules/@smithy/core": {++ "version": "3.36.2",++ "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.36.2.tgz",++ "integrity": "sha512-oRw0rB9QSUiCZfEs23G5F4Af+b2rQt1lItIICWu5IejFNd+uPM8SWqiKLMpQ6bX1Q7slsLbjuoE2niErFtkVPQ==",++ "license": "Apache-2.0",++ "dependencies": {++ "@smithy/types": "^4.19.0",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=18.0.0"++ }++ },++ "node_modules/@smithy/credential-provider-imds": {++ "version": "4.5.2",++ "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",++ "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",++ "license": "Apache-2.0",++ "dependencies": {++ "@smithy/core": "^3.33.2",++ "@smithy/types": "^4.17.2",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=18.0.0"++ }++ },++ "node_modules/@smithy/fetch-http-handler": {++ "version": "5.8.0",++ "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz",++ "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==",++ "license": "Apache-2.0",++ "dependencies": {++ "@smithy/core": "^3.33.3",++ "@smithy/types": "^4.18.0",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=18.0.0"++ }++ },++ "node_modules/@smithy/node-http-handler": {++ "version": "4.12.1",++ "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz",++ "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==",++ "license": "Apache-2.0",++ "dependencies": {++ "@smithy/core": "^3.33.3",++ "@smithy/types": "^4.18.0",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=18.0.0"++ }++ },++ "node_modules/@smithy/signature-v4": {++ "version": "5.7.4",++ "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz",++ "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==",++ "license": "Apache-2.0",++ "dependencies": {++ "@smithy/core": "^3.35.0",++ "@smithy/types": "^4.19.0",++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=18.0.0"++ }++ },++ "node_modules/@smithy/types": {++ "version": "4.19.0",++ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz",++ "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==",++ "license": "Apache-2.0",++ "dependencies": {++ "tslib": "^2.6.2"++ },++ "engines": {++ "node": ">=18.0.0"++ }++ },"node_modules/ajv": {"version": "8.20.0","resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",@@@ -185,6 +603,12 @@}}},++ "node_modules/bowser": {++ "version": "2.14.1",++ "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",++ "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",++ "license": "MIT"++ },"node_modules/fast-deep-equal": {"version": "3.1.3","resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",@@@ -314,6 +738,10 @@"resolved": "examples/webhook-consumer","link": true},++ "node_modules/openvibe-example-zone-s3": {++ "resolved": "examples/zone-s3",++ "link": true++ },"node_modules/openvibe-sdk": {"version": "0.60.0","resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0",@@@ -488,6 +916,12 @@"node": ">= 10.x"}},++ "node_modules/tslib": {++ "version": "2.8.1",++ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",++ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",++ "license": "0BSD"++ },"node_modules/ws": {"version": "8.21.3","resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",@@@ -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'];++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'];// 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 and go-live');++assert.deepEqual(dirs, EXPECTED, 'the nine charter examples, ai-chat, go-live and zone-s3');function sources(dir) {const out = [];@@@ -48,10 +48,10 @@ for (const name of EXPECTED) {assert.match(readme, /^## What it proves$/m, `${name}: README says what it proves`);const envExample = fs.readFileSync(path.join(dir, '.env.example'), 'utf8');-- const declared = new Set([...envExample.matchAll(/^#?\s*(OV_[A-Z_]+)=/gm)].map((m) => m[1]));++ const declared = new Set([...envExample.matchAll(/^#?\s*(OV_[A-Z0-9_]+)=/gm)].map((m) => m[1]));const used = new Set();for (const file of sources(dir)) {-- for (const m of fs.readFileSync(file, 'utf8').matchAll(/env\.(OV_[A-Z_]+)/g)) used.add(m[1]);++ for (const m of fs.readFileSync(file, 'utf8').matchAll(/env\.(OV_[A-Z0-9_]+)/g)) used.add(m[1]);}for (const v of used) assert.ok(declared.has(v), `${name}: ${v} is read by the code but missing from .env.example`);}
@@ -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). Eleven 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). Twelve 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 |
+| [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` |
Each example folder has a README, a `.env.example`, its own `package.json` and a
Each example folder has a README, a `.env.example`, its own `package.json` and a
@@ -291,7 +292,7 @@ test/public-surface.test.js, test/structure.test.js
@@ -291,7 +292,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) and the go-live example (Live over WHIP from code)
+ 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)
- 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": [
- "eleven 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)",
+ "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)",
"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,7 @@
@@ -0,0 +1,7 @@
+# An access key from the Zone console (openvibe.zone → your project → Access keys). Its secret is shown once.
+OV_S3_ACCESS_KEY_ID=
+OV_S3_SECRET_ACCESS_KEY=
+# The zone (bucket) to use; the key must be allowed to read and write it.
+OV_ZONE=
+# Optional:
+# OV_S3_ENDPOINT=https://s3.openvibe.media
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
+# Zone over S3 (AWS SDK v3)
+
+Use [OpenVibe.Zone](https://openvibe.zone)'s private buckets ("zones") from Node with the official
+AWS SDK v3, as you would S3. Zone's S3 endpoint is **path-style, region `us-east-1`, Signature
+Version 4**.
+
+```bash
+node --env-file=.env zone.js
+# put s3://assets/openvibe-example/hello-1729….txt
+# list 1 object(s) under openvibe-example/
+# share a 10-minute link reads the same bytes
+# get the same bytes (62 bytes)
+# delete s3://assets/openvibe-example/hello-1729….txt
+
+node --env-file=.env zone.js put ./report.pdf reports/2026-10.pdf
+node --env-file=.env zone.js ls reports/
+node --env-file=.env zone.js share reports/2026-10.pdf 86400
+```
+
+## What it proves
+
+- **An `S3Client` for Zone** is three settings: `endpoint: 'https://s3.openvibe.media'`,
+ `region: 'us-east-1'` and `forcePathStyle: true`. Everything else is the SDK's defaults, including
+ its flexible checksums and aws-chunked uploads, which Zone accepts.
+- **The everyday calls:**
+ - `PutObjectCommand` with a content type;
+ - `GetObjectCommand`;
+ - `paginateListObjectsV2`, which follows pages;
+ - a presigned GET link from `@aws-sdk/s3-request-presigner`, lasting 1 s to 7 days, which opens
+ without credentials until it expires or its key is revoked;
+ - `DeleteObjectCommand`.
+- **The access key** is the only credential. It belongs to one project environment and may be
+ limited to some zones and to read or write. Its secret is shown once, when it is issued, and it
+ signs requests: it never travels, appears in a link or is printed.
+- **Large files:** one request carries at most 256 MB. `@aws-sdk/lib-storage`'s `Upload` (or
+ `aws s3 cp`, or rclone) sends bigger files in parts, under S3's rules (every part but the last at
+ least 5 MiB).
+- A zone's objects are OpenVibe.Media objects. Media's API
+ (`/api/v2/:project_id/objects` with `namespace` set to the zone's id) reads the same files.
+
+## Files
+
+| File | What |
+|---|---|
+| `zone.js` | `loadConfig()`, `createS3()`, `put()`, `get()`, `list()`, `share()`, `remove()`, `roundTrip()`, CLI |
+| `test/smoke.test.js` | the real AWS SDK v3 against a local stand-in for Zone's S3 endpoint |
+
+## Run the smoke test
+
+```bash
+npm test
+```
+
+The test runs the real `@aws-sdk/client-s3` and `@aws-sdk/s3-request-presigner` against a stand-in
+for Zone's endpoint on 127.0.0.1. The stand-in handles path-style requests and decodes aws-chunked
+bodies, as OpenVibe.Media's `server/s3/routes.js` does. It checks:
+- the signature's scope (`<key id>/<date>/us-east-1/s3/aws4_request`);
+- path-style addressing;
+- the list paginator following continuation tokens;
+- the share link's expiry and that it opens without credentials;
+- `NoSuchKey` after a delete;
+- that a key from elsewhere is refused;
+- that the secret never appears on the wire or in output.
+
+## Run it against the real platform
+
+1. Sign in at [openvibe.zone](https://openvibe.zone), pick a project (or make one at
+ openvibe.services) and create a zone, for example `assets`. You can also create zones with
+ `openvibe-sdk/zone` (`zone.zones.create(projectId, { env, name, quota_bytes })`) from an app of
+ the project.
+2. Issue an access key that can read and write that zone, and copy its secret, which is shown once.
+3. `cp .env.example .env`, fill in `OV_S3_ACCESS_KEY_ID`, `OV_S3_SECRET_ACCESS_KEY` and `OV_ZONE`,
+ then `npm install && npm start`.
+
+The round trip writes one small object under `openvibe-example/` and deletes it again.
+
+The same settings work in other tools:
+- the AWS CLI: `aws --endpoint-url https://s3.openvibe.media s3 ls s3://assets/`;
+- rclone: `provider Other`, the endpoint above, `no_check_bucket true`.
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "name": "openvibe-example-zone-s3",
+ "version": "0.1.0",
+ "private": true,
+ "description": "OpenVibe.Zone over S3 with the official AWS SDK v3: put, get, list, a presigned share link and delete in a private zone.",
+ "license": "MIT",
+ "main": "zone.js",
+ "scripts": {
+ "start": "node --env-file-if-exists=.env zone.js",
+ "test": "node test/smoke.test.js"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "dependencies": {
+ "@aws-sdk/client-s3": "3.1148.0",
+ "@aws-sdk/s3-request-presigner": "3.1148.0"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
+'use strict';
+/**
+ * Smoke test: zone.js through the real AWS SDK v3 against a local stand-in for Zone's S3 endpoint (path-style
+ * /<zone>/<key>, region us-east-1, Signature Version 4, aws-chunked bodies as OpenVibe.Media's server/s3/routes.js
+ * reads them). It checks what the SDK sends (the signing region and service, path-style addressing, the access key
+ * id, never the secret) and what comes back. The stand-in listens on 127.0.0.1 only; nothing reaches the network.
+ */
+const assert = require('node:assert/strict');
+const http = require('node:http');
+const { loadConfig, createS3, put, get, list, share, remove, roundTrip } = require('../zone');
+
+const ACCESS = 'ovk_TESTKEYNOTREAL';
+const SECRET = 'test-secret-not-real-0123456789';
+const objects = new Map(); // "<zone>/<key>" → { body, type }
+const seen = [];
+
+/** aws-chunked (STREAMING-UNSIGNED-PAYLOAD-TRAILER or signed chunks): <hex size>[;…]\r\n<data>\r\n … 0\r\n<trailers>\r\n\r\n */
+function dechunk(buf) {
+ const out = [];
+ let i = 0;
+ for (;;) {
+ const eol = buf.indexOf('\r\n', i);
+ const size = parseInt(buf.subarray(i, eol).toString().split(';')[0], 16);
+ if (!size) break;
+ out.push(buf.subarray(eol + 2, eol + 2 + size));
+ i = eol + 2 + size + 2;
+ }
+ return Buffer.concat(out);
+}
+
+const xml = (s) => String(s).replace(/&/g, '&').replace(/</g, '<');
+const server = http.createServer(async (req, res) => {
+ const chunks = [];
+ for await (const c of req) chunks.push(c);
+ let body = Buffer.concat(chunks);
+ const u = new URL(req.url, 'http://x');
+ const auth = String(req.headers.authorization || '');
+ const presigned = u.searchParams.get('X-Amz-Algorithm') === 'AWS4-HMAC-SHA256';
+ const cred = presigned ? u.searchParams.get('X-Amz-Credential') : (/Credential=([^,]+)/.exec(auth) || [])[1];
+ seen.push({ method: req.method, path: u.pathname, cred, presigned, auth, sha: req.headers['x-amz-content-sha256'], query: u.search });
+ // Signed by the access key, for us-east-1 and s3, as Zone checks; the secret never travels.
+ if (!cred || !/^ovk_TESTKEYNOTREAL\/\d{8}\/us-east-1\/s3\/aws4_request$/.test(cred)) { res.writeHead(403); return res.end('<Error><Code>AccessDenied</Code></Error>'); }
+ if (JSON.stringify(req.headers).includes(SECRET) || req.url.includes(SECRET)) { res.writeHead(400); return res.end('secret on the wire'); }
+ const [, zone, ...rest] = decodeURIComponent(u.pathname).split('/');
+ const key = rest.join('/');
+ if (req.method === 'PUT' && key) {
+ if (/STREAMING/.test(String(req.headers['x-amz-content-sha256'] || '')) || /aws-chunked/.test(String(req.headers['content-encoding'] || ''))) body = dechunk(body);
+ objects.set(`${zone}/${key}`, { body, type: req.headers['content-type'] || 'application/octet-stream' });
+ res.writeHead(200, { ETag: '"etag-1"' });
+ return res.end();
+ }
+ if (req.method === 'GET' && key) {
+ const o = objects.get(`${zone}/${key}`);
+ if (!o) { res.writeHead(404, { 'Content-Type': 'application/xml' }); return res.end('<Error><Code>NoSuchKey</Code></Error>'); }
+ res.writeHead(200, { 'Content-Type': o.type, 'Content-Length': o.body.length, ETag: '"etag-1"' });
+ return res.end(o.body);
+ }
+ if (req.method === 'DELETE' && key) { objects.delete(`${zone}/${key}`); res.writeHead(204); return res.end(); }
+ if (req.method === 'GET' && !key && u.searchParams.get('list-type') === '2') {
+ const prefix = u.searchParams.get('prefix') || '';
+ const all = [...objects.keys()].filter((k) => k.startsWith(`${zone}/${prefix}`)).map((k) => k.slice(zone.length + 1)).sort();
+ // Two to a page here, so the SDK's paginator follows continuation tokens.
+ const start = Number(u.searchParams.get('continuation-token') || 0);
+ const page = all.slice(start, start + 2);
+ const more = start + 2 < all.length;
+ res.writeHead(200, { 'Content-Type': 'application/xml' });
+ return res.end(`<?xml version="1.0" encoding="UTF-8"?><ListBucketResult><Name>${xml(zone)}</Name><Prefix>${xml(prefix)}</Prefix><KeyCount>${page.length}</KeyCount><MaxKeys>2</MaxKeys><IsTruncated>${more}</IsTruncated>${more ? `<NextContinuationToken>${start + 2}</NextContinuationToken>` : ''}${page.map((k) => `<Contents><Key>${xml(k)}</Key><Size>${objects.get(`${zone}/${k}`).body.length}</Size><LastModified>2026-10-22T00:00:00.000Z</LastModified><ETag>"etag-1"</ETag></Contents>`).join('')}</ListBucketResult>`);
+ }
+ res.writeHead(400); res.end();
+});
+
+(async () => {
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const endpoint = `http://127.0.0.1:${server.address().port}`;
+ try {
+ assert.throws(() => loadConfig({}), /missing environment variables: OV_S3_ACCESS_KEY_ID, OV_S3_SECRET_ACCESS_KEY, OV_ZONE/);
+ assert.throws(() => loadConfig({ OV_S3_ACCESS_KEY_ID: ACCESS, OV_S3_SECRET_ACCESS_KEY: SECRET, OV_ZONE: 'My_Bucket' }), /zone name/);
+ assert.throws(() => loadConfig({ OV_S3_ACCESS_KEY_ID: ACCESS, OV_S3_SECRET_ACCESS_KEY: SECRET, OV_ZONE: 'a--b' }), /zone name/);
+ assert.equal(loadConfig({ OV_S3_ACCESS_KEY_ID: ACCESS, OV_S3_SECRET_ACCESS_KEY: SECRET, OV_ZONE: 'assets' }).endpoint, 'https://s3.openvibe.media');
+ const config = loadConfig({ OV_S3_ACCESS_KEY_ID: ACCESS, OV_S3_SECRET_ACCESS_KEY: SECRET, OV_ZONE: 'assets', OV_S3_ENDPOINT: endpoint });
+ const s3 = createS3(config);
+
+ // Put and get: path-style, signed for us-east-1 by the access key; the bytes and type come back.
+ await put(s3, 'assets', 'notes/a.txt', 'first');
+ await put(s3, 'assets', 'notes/b.json', Buffer.from('{"b":2}'));
+ await put(s3, 'assets', 'notes/c.txt', 'third');
+ const putReq = seen.find((x) => x.method === 'PUT');
+ assert.equal(putReq.path, '/assets/notes/a.txt', 'path-style /<zone>/<key>');
+ assert.match(putReq.auth, /^AWS4-HMAC-SHA256 Credential=ovk_TESTKEYNOTREAL\/\d{8}\/us-east-1\/s3\/aws4_request, SignedHeaders=[^,]+, Signature=[0-9a-f]{64}$/);
+ const a = await get(s3, 'assets', 'notes/a.txt');
+ assert.deepEqual([a.bytes.toString(), a.contentType], ['first', 'text/plain; charset=utf-8']);
+ assert.equal((await get(s3, 'assets', 'notes/b.json')).contentType, 'application/json');
+
+ // List follows pages (two to a page here).
+ assert.deepEqual((await list(s3, 'assets', 'notes/')).map((o) => o.key), ['notes/a.txt', 'notes/b.json', 'notes/c.txt']);
+ assert.ok(seen.filter((x) => x.query.includes('list-type=2')).length >= 2, 'the paginator asked for a second page');
+
+ // A share link: presigned GET, at most 7 days, opens without credentials.
+ await assert.rejects(share(s3, 'assets', 'notes/a.txt', 8 * 24 * 3600), /1 s to 7 days/);
+ const url = await share(s3, 'assets', 'notes/a.txt', 600);
+ const link = new URL(url);
+ assert.equal(link.pathname, '/assets/notes/a.txt');
+ assert.equal(link.searchParams.get('X-Amz-Expires'), '600');
+ assert.ok(!url.includes(SECRET), 'the secret is never in a link');
+ assert.equal(await (await fetch(url)).text(), 'first');
+
+ // Delete, then a missing key is the SDK's NoSuchKey.
+ await remove(s3, 'assets', 'notes/a.txt');
+ await assert.rejects(get(s3, 'assets', 'notes/a.txt'), (err) => err.name === 'NoSuchKey');
+
+ // The CLI's round trip.
+ const lines = [];
+ const r = await roundTrip(s3, 'assets', { log: (l) => lines.push(l) });
+ assert.equal(r.same, true);
+ assert.ok(!objects.has(`assets/${r.key}`), 'the round trip cleans up');
+ assert.deepEqual(lines.map((l) => l.split(/\s+/)[0]), ['put', 'list', 'share', 'get', 'delete']);
+ assert.ok(lines.every((l) => !l.includes(SECRET)));
+
+ // A key from somewhere else is refused (the stand-in checks the credential scope like Zone).
+ const other = createS3({ ...config, accessKeyId: 'ovk_OTHER' });
+ await assert.rejects(get(other, 'assets', 'notes/b.json'), (err) => err.$metadata && err.$metadata.httpStatusCode === 403);
+
+ console.log('zone-s3 smoke: put, get, paged list, share link, delete and the round trip through the AWS SDK v3; SigV4 for us-east-1, path-style');
+ } finally {
+ server.close();
+ }
+})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -0,0 +1,112 @@
+'use strict';
+/**
+ * OpenVibe.Zone over S3 with the official AWS SDK v3: private buckets ("zones") of an OpenVibe project, reached by any
+ * S3 tool. A zone's objects are OpenVibe.Media objects, so the same files are readable through Media's API too.
+ *
+ * Zone's S3 endpoint is path-style, region us-east-1, Signature Version 4 (https://openvibe.zone/docs#s3). An access
+ * key belongs to one project environment and may be limited to some zones and to read or write; its secret is shown
+ * once, when it is issued in the Zone console. This file shows the everyday calls: put, get, list, a presigned link
+ * to share, and delete.
+ *
+ * node --env-file=.env zone.js # the round trip: put, list, share, get, delete
+ * node --env-file=.env zone.js put ./report.pdf reports/2026-10.pdf
+ * node --env-file=.env zone.js ls reports/
+ * node --env-file=.env zone.js share reports/2026-10.pdf 86400
+ *
+ * Larger files: one request carries at most 256 MB; @aws-sdk/lib-storage's Upload (or `aws s3 cp`, rclone) sends
+ * bigger ones in parts, which Zone accepts with S3's rules.
+ */
+const fs = require('node:fs');
+const path = require('node:path');
+const { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand, paginateListObjectsV2 } = require('@aws-sdk/client-s3');
+const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
+
+const MAX_SHARE_SECONDS = 7 * 24 * 3600; // Zone's limit for a presigned URL, as S3's
+
+function loadConfig(env = process.env) {
+ const missing = ['OV_S3_ACCESS_KEY_ID', 'OV_S3_SECRET_ACCESS_KEY', 'OV_ZONE'].filter((k) => !env[k]);
+ if (missing.length) throw new Error(`missing environment variables: ${missing.join(', ')}`);
+ // Zone's own rule for names: 3 to 63 lowercase letters and digits, single hyphens between them.
+ if (!/^(?=.{3,63}$)[a-z0-9]+(-[a-z0-9]+)*$/.test(env.OV_ZONE)) throw new Error('OV_ZONE is a zone name (3 to 63 lowercase letters, digits and single hyphens)');
+ return {
+ endpoint: String(env.OV_S3_ENDPOINT || 'https://s3.openvibe.media').replace(/\/+$/, ''),
+ zone: env.OV_ZONE,
+ accessKeyId: env.OV_S3_ACCESS_KEY_ID,
+ secretAccessKey: env.OV_S3_SECRET_ACCESS_KEY,
+ };
+}
+
+/** An S3 client for Zone: its endpoint, path-style addressing, us-east-1. The SDK's own defaults do the rest. */
+function createS3(config) {
+ return new S3Client({
+ endpoint: config.endpoint, region: 'us-east-1', forcePathStyle: true,
+ credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey },
+ });
+}
+
+const TYPES = { '.txt': 'text/plain; charset=utf-8', '.json': 'application/json', '.html': 'text/html; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.pdf': 'application/pdf', '.mp4': 'video/mp4' };
+const typeOf = (key) => TYPES[path.extname(key).toLowerCase()] || 'application/octet-stream';
+
+async function put(s3, zone, key, body, contentType = typeOf(key)) {
+ const out = await s3.send(new PutObjectCommand({ Bucket: zone, Key: key, Body: body, ContentType: contentType }));
+ return { key, etag: out.ETag };
+}
+
+async function get(s3, zone, key) {
+ const out = await s3.send(new GetObjectCommand({ Bucket: zone, Key: key }));
+ return { bytes: Buffer.from(await out.Body.transformToByteArray()), contentType: out.ContentType };
+}
+
+/** Every key under a prefix, page after page (Zone pages like S3: 1000 a page). */
+async function list(s3, zone, prefix = '') {
+ const keys = [];
+ for await (const page of paginateListObjectsV2({ client: s3 }, { Bucket: zone, Prefix: prefix })) {
+ for (const o of page.Contents || []) keys.push({ key: o.Key, size: o.Size, modified: o.LastModified });
+ }
+ return keys;
+}
+
+/** A link anyone can open until it expires (at most 7 days), or until the key that signed it is revoked. */
+async function share(s3, zone, key, seconds = 3600) {
+ if (!(seconds > 0 && seconds <= MAX_SHARE_SECONDS)) throw new Error(`a share link lasts 1 s to 7 days (${MAX_SHARE_SECONDS} s)`);
+ return await getSignedUrl(s3, new GetObjectCommand({ Bucket: zone, Key: key }), { expiresIn: seconds });
+}
+
+async function remove(s3, zone, key) {
+ await s3.send(new DeleteObjectCommand({ Bucket: zone, Key: key }));
+}
+
+/** The round trip the CLI runs with no arguments. */
+async function roundTrip(s3, zone, { log = console.log, fetchImpl = globalThis.fetch } = {}) {
+ const key = `openvibe-example/hello-${Date.now()}.txt`;
+ const text = `Hello from the zone-s3 example, ${new Date().toISOString()}\n`;
+ await put(s3, zone, key, text);
+ log(`put s3://${zone}/${key}`);
+ const listed = await list(s3, zone, 'openvibe-example/');
+ log(`list ${listed.length} object(s) under openvibe-example/`);
+ const url = await share(s3, zone, key, 600);
+ const viaLink = await (await fetchImpl(url)).text();
+ log(`share a 10-minute link reads ${viaLink === text ? 'the same bytes' : 'something else'}`);
+ const back = (await get(s3, zone, key)).bytes.toString('utf8');
+ log(`get ${back === text ? 'the same bytes' : 'something else'} (${Buffer.byteLength(back)} bytes)`);
+ await remove(s3, zone, key);
+ log(`delete s3://${zone}/${key}`);
+ return { key, same: back === text && viaLink === text, listed: listed.length };
+}
+
+module.exports = { loadConfig, createS3, put, get, list, share, remove, roundTrip, MAX_SHARE_SECONDS };
+
+if (require.main === module) {
+ (async () => {
+ const config = loadConfig();
+ const s3 = createS3(config);
+ const [cmd, a, b] = process.argv.slice(2);
+ if (!cmd) { const r = await roundTrip(s3, config.zone); process.exit(r.same ? 0 : 1); }
+ if (cmd === 'put') console.log((await put(s3, config.zone, b || path.basename(a), fs.readFileSync(a))).etag);
+ else if (cmd === 'get') process.stdout.write((await get(s3, config.zone, a)).bytes);
+ else if (cmd === 'ls') for (const o of await list(s3, config.zone, a || '')) console.log(`${String(o.size).padStart(12)} ${o.key}`);
+ else if (cmd === 'share') console.log(await share(s3, config.zone, a, b ? Number(b) : 3600));
+ else if (cmd === 'rm') await remove(s3, config.zone, a);
+ else throw new Error('usage: zone.js [put <file> [key] | get <key> | ls [prefix] | share <key> [seconds] | rm <key>]');
+ })().catch((err) => { console.error(err.name && err.$metadata ? `${err.name}: ${err.message}` : err.message); process.exit(1); });
+}
@@ -146,12 +146,430 @@
@@ -146,12 +146,430 @@
"node": ">=22"
"node": ">=22"
}
}
},
},
+ "examples/zone-s3": {
+ "name": "openvibe-example-zone-s3",
+ "version": "0.1.0",
+ "license": "MIT",
+ "dependencies": {
+ "@aws-sdk/client-s3": "3.1148.0",
+ "@aws-sdk/s3-request-presigner": "3.1148.0"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/@aws-sdk/checksums": {
+ "version": "3.1001.5",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1001.5.tgz",
+ "integrity": "sha512-6erto9nK2OZO/woEgmFIaPh5gChgdNd7nL6jgAf8TAXz3/QHC8Pan2TFe9GYp3IxOIKCCGWPylSjtOHKfNzQlw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.978.5",
+ "@aws-sdk/types": "^3.975.0",
+ "@smithy/core": "^3.36.1",
+ "@smithy/types": "^4.19.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/client-s3": {
+ "version": "3.1148.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1148.0.tgz",
+ "integrity": "sha512-d2AcfJCZTi4w5zJZD7H/XeVRgJy1vOD5EfO1Y3pKAwfp8pWz2dyJ8Q3vOJFbRiTIQF7c4mB+aptNUyCE4i4kqw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/checksums": "^3.1001.1",
+ "@aws-sdk/core": "^3.978.1",
+ "@aws-sdk/credential-provider-node": "^3.972.84",
+ "@aws-sdk/middleware-sdk-s3": "^3.972.77",
+ "@aws-sdk/signature-v4-multi-region": "^3.996.47",
+ "@aws-sdk/types": "^3.974.6",
+ "@smithy/core": "^3.35.0",
+ "@smithy/fetch-http-handler": "^5.8.0",
+ "@smithy/node-http-handler": "^4.12.1",
+ "@smithy/types": "^4.19.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/core": {
+ "version": "3.978.5",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.5.tgz",
+ "integrity": "sha512-ruI0AWqcrfL20XDS6SwZIJFPOBZ6N0ADa9nLzWlBhxk1aoUjbUX3JkTR9vEmk7o35XpAHi68ahZ4T6RE2U9XyA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.975.0",
+ "@aws-sdk/xml-builder": "^3.972.41",
+ "@aws/lambda-invoke-store": "^0.3.0",
+ "@smithy/core": "^3.36.1",
+ "@smithy/signature-v4": "^5.7.4",
+ "@smithy/types": "^4.19.0",
+ "bowser": "^2.11.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-env": {
+ "version": "3.972.76",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.76.tgz",
+ "integrity": "sha512-YxmL1Ar7bV9ZkJvtv+lj+MF6le76NnZnbmgySyZuxpZaCv0/1lZ3KxKy6RIj1h9ui2hWnVjZxbmTMeO0y/NrQA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.978.5",
+ "@aws-sdk/types": "^3.975.0",
+ "@smithy/core": "^3.36.1",
+ "@smithy/types": "^4.19.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-http": {
+ "version": "3.972.78",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.78.tgz",
+ "integrity": "sha512-3VHknDRngoebsaqyT1iR75b5cBT2zfEtODK6yYmYltljkK1HEAQLs2FEd1IpWqAeOYdMGaG9wikngWLTufY7RA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.978.5",
+ "@aws-sdk/types": "^3.975.0",
+ "@smithy/core": "^3.36.1",
+ "@smithy/fetch-http-handler": "^5.8.0",
+ "@smithy/node-http-handler": "^4.12.1",
+ "@smithy/types": "^4.19.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-ini": {
+ "version": "3.973.21",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.21.tgz",
+ "integrity": "sha512-aLx9hbCX6mGAyv4ZZd8UagJ4RASHVJw6BFDSIyuKmLkqNL6H47EjBPK6qaJ4B5TDV6mc+vMa0SJFYyo4mkvVQg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.978.5",
+ "@aws-sdk/credential-provider-env": "^3.972.76",
+ "@aws-sdk/credential-provider-http": "^3.972.78",
+ "@aws-sdk/credential-provider-login": "^3.972.83",
+ "@aws-sdk/credential-provider-process": "^3.972.76",
+ "@aws-sdk/credential-provider-sso": "^3.973.20",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.82",
+ "@aws-sdk/nested-clients": "^3.997.50",
+ "@aws-sdk/types": "^3.975.0",
+ "@smithy/core": "^3.36.1",
+ "@smithy/credential-provider-imds": "^4.5.2",
+ "@smithy/types": "^4.19.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-login": {
+ "version": "3.972.83",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.83.tgz",
+ "integrity": "sha512-9bJU6FjRacF4FK/QHf8Dyxhur7B1grRj8C1Yh4IkpoFW2exQflPdCv0VrbemLW9ZctLNle+AE7TETs9KNvuLYg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.978.5",
+ "@aws-sdk/nested-clients": "^3.997.50",
+ "@aws-sdk/types": "^3.975.0",
+ "@smithy/core": "^3.36.1",
+ "@smithy/types": "^4.19.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-node": {
+ "version": "3.972.88",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.88.tgz",
+ "integrity": "sha512-AG0KID5qhQPzrFkh5b0Cws1Amytvzo0L0Xj6hMgJXEwD6ZXAqninDhSNAWJiN5zU4YVVZnuidnqwPwYJ04IamA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/credential-provider-env": "^3.972.76",
+ "@aws-sdk/credential-provider-http": "^3.972.78",
+ "@aws-sdk/credential-provider-ini": "^3.973.21",
+ "@aws-sdk/credential-provider-process": "^3.972.76",
+ "@aws-sdk/credential-provider-sso": "^3.973.20",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.82",
+ "@aws-sdk/types": "^3.975.0",
+ "@smithy/core": "^3.36.1",
+ "@smithy/credential-provider-imds": "^4.5.2",
+ "@smithy/types": "^4.19.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-process": {
+ "version": "3.972.76",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.76.tgz",
+ "integrity": "sha512-pdhKFTXv1zPYhh92MTlp0jVbLWvUEV9v+rziuEkoskGGQ2iJskxHTO38nA8UAbT/N4IM3n8OZJ9rIY5IBO15/A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.978.5",
+ "@aws-sdk/types": "^3.975.0",
+ "@smithy/core": "^3.36.1",
+ "@smithy/types": "^4.19.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-sso": {
+ "version": "3.973.20",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.20.tgz",
+ "integrity": "sha512-DlxrAB3MnjNLx/da7b89W+3DyJQYso/q7Ns15MsST2IV2nkAYQOYTUCaffNSjgrpMP99Dm2mV2jxzcbyF94ZfA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.978.5",
+ "@aws-sdk/nested-clients": "^3.997.50",
+ "@aws-sdk/token-providers": "3.1155.0",
+ "@aws-sdk/types": "^3.975.0",
+ "@smithy/core": "^3.36.1",
+ "@smithy/types": "^4.19.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-web-identity": {
+ "version": "3.972.82",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.82.tgz",
+ "integrity": "sha512-LUo46IqPTJ4xKBsrLuoaxiv/D2GTBCcFDMD1y3jKIqPR/p7Rw14RJFZ0iRWftqZtyLPQX9JMGnSi3mBu4y/pNA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.978.5",
+ "@aws-sdk/nested-clients": "^3.997.50",
+ "@aws-sdk/types": "^3.975.0",
+ "@smithy/core": "^3.36.1",
+ "@smithy/types": "^4.19.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/middleware-sdk-s3": {
+ "version": "3.972.81",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.81.tgz",
+ "integrity": "sha512-jfanv1pV0sJFZw6/DPiNjvK7B478god0zOum5yIWo+/+1Gy+CSXWpGBWx+3nBJyDvjcu/2kUMr4ADUp9BHH34Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.978.5",
+ "@aws-sdk/signature-v4-multi-region": "^3.996.49",
+ "@aws-sdk/types": "^3.975.0",
+ "@smithy/core": "^3.36.1",
+ "@smithy/types": "^4.19.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/nested-clients": {
+ "version": "3.997.50",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.50.tgz",
+ "integrity": "sha512-nKb8qu4QsnpZTq/6JIXc72mzuzfh7BK4sUQkpQznPec2z4W7Ob2xYkcM4i5e1yGTBnbJVeFv/0sf3jcz85hclA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.978.5",
+ "@aws-sdk/signature-v4-multi-region": "^3.996.49",
+ "@aws-sdk/types": "^3.975.0",
+ "@smithy/core": "^3.36.1",
+ "@smithy/fetch-http-handler": "^5.8.0",
+ "@smithy/node-http-handler": "^4.12.1",
+ "@smithy/types": "^4.19.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/s3-request-presigner": {
+ "version": "3.1148.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1148.0.tgz",
+ "integrity": "sha512-k9UM9y4fSUl6QzO2tluO2/MzQRghVhF3kbQ2DXAcZAEECvarGa8zxI4oTEq2VHthhNyYuBuOmLvGD8EH/1vtTQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.978.1",
+ "@aws-sdk/signature-v4-multi-region": "^3.996.47",
+ "@aws-sdk/types": "^3.974.6",
+ "@smithy/core": "^3.35.0",
+ "@smithy/types": "^4.19.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/signature-v4-multi-region": {
+ "version": "3.996.49",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.49.tgz",
+ "integrity": "sha512-nvuAZNRrRRVCiARRKsYlQ0VnXiEQiq7vXPH+UA+LSo+kN3Djl9eo/V2r0udkw85PnsNshmv2Em1clld5M3j67w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.975.0",
+ "@smithy/signature-v4": "^5.7.4",
+ "@smithy/types": "^4.19.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/token-providers": {
+ "version": "3.1155.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1155.0.tgz",
+ "integrity": "sha512-iIcQnYLhWuq9qOLQbIWFrxF3PF0svVeZvHnUS4DUnO4zUFmFHIyGyjqrntXG+e/5PUgfavV0pYfDIKHxTzN2LA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.978.5",
+ "@aws-sdk/nested-clients": "^3.997.50",
+ "@aws-sdk/types": "^3.975.0",
+ "@smithy/core": "^3.36.1",
+ "@smithy/types": "^4.19.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/types": {
+ "version": "3.975.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.975.0.tgz",
+ "integrity": "sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.19.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/xml-builder": {
+ "version": "3.972.41",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz",
+ "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.19.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws/lambda-invoke-store": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
+ "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/@electric-sql/pglite": {
"node_modules/@electric-sql/pglite": {
"version": "0.5.8",
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
"resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
"integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
"integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
"license": "Apache-2.0"
"license": "Apache-2.0"
},
},
+ "node_modules/@smithy/core": {
+ "version": "3.36.2",
+ "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.36.2.tgz",
+ "integrity": "sha512-oRw0rB9QSUiCZfEs23G5F4Af+b2rQt1lItIICWu5IejFNd+uPM8SWqiKLMpQ6bX1Q7slsLbjuoE2niErFtkVPQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.19.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/credential-provider-imds": {
+ "version": "4.5.2",
+ "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",
+ "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.33.2",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/fetch-http-handler": {
+ "version": "5.8.0",
+ "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz",
+ "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.18.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/node-http-handler": {
+ "version": "4.12.1",
+ "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz",
+ "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.18.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/signature-v4": {
+ "version": "5.7.4",
+ "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz",
+ "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.35.0",
+ "@smithy/types": "^4.19.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/types": {
+ "version": "4.19.0",
+ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz",
+ "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/ajv": {
"node_modules/ajv": {
"version": "8.20.0",
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@ -185,6 +603,12 @@
@@ -185,6 +603,12 @@
}
}
}
}
},
},
+ "node_modules/bowser": {
+ "version": "2.14.1",
+ "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
+ "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
+ "license": "MIT"
+ },
"node_modules/fast-deep-equal": {
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -314,6 +738,10 @@
@@ -314,6 +738,10 @@
"resolved": "examples/webhook-consumer",
"resolved": "examples/webhook-consumer",
"link": true
"link": true
},
},
+ "node_modules/openvibe-example-zone-s3": {
+ "resolved": "examples/zone-s3",
+ "link": true
+ },
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
"version": "0.60.0",
"version": "0.60.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.60.0",
@@ -488,6 +916,12 @@
@@ -488,6 +916,12 @@
"node": ">= 10.x"
"node": ">= 10.x"
}
}
},
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
"node_modules/ws": {
"node_modules/ws": {
"version": "8.21.3",
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
@@ -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'];
+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'];
// 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 and go-live');
+assert.deepEqual(dirs, EXPECTED, 'the nine charter examples, ai-chat, go-live and zone-s3');
function sources(dir) {
function sources(dir) {
const out = [];
const out = [];
@@ -48,10 +48,10 @@ for (const name of EXPECTED) {
@@ -48,10 +48,10 @@ for (const name of EXPECTED) {
assert.match(readme, /^## What it proves$/m, `${name}: README says what it proves`);
assert.match(readme, /^## What it proves$/m, `${name}: README says what it proves`);
const envExample = fs.readFileSync(path.join(dir, '.env.example'), 'utf8');
const envExample = fs.readFileSync(path.join(dir, '.env.example'), 'utf8');
- const declared = new Set([...envExample.matchAll(/^#?\s*(OV_[A-Z_]+)=/gm)].map((m) => m[1]));
+ const declared = new Set([...envExample.matchAll(/^#?\s*(OV_[A-Z0-9_]+)=/gm)].map((m) => m[1]));
const used = new Set();
const used = new Set();
for (const file of sources(dir)) {
for (const file of sources(dir)) {
- for (const m of fs.readFileSync(file, 'utf8').matchAll(/env\.(OV_[A-Z_]+)/g)) used.add(m[1]);
+ for (const m of fs.readFileSync(file, 'utf8').matchAll(/env\.(OV_[A-Z0-9_]+)/g)) used.add(m[1]);
}
}
for (const v of used) assert.ok(declared.has(v), `${name}: ${v} is read by the code but missing from .env.example`);
for (const v of used) assert.ok(declared.has(v), `${name}: ${v} is read by the code but missing from .env.example`);
}
}