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)

OP OpenVibers committed 22 days ago · 052a8378ea · 9 files, +786 −7

Changes

ddiff --git a/README.md b/README.md
iindex 07de708..26290b2 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). 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
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 |
++| [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)
ddiff --git a/STATUS.json b/STATUS.json
iindex 74945dd..a8e6af8 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": [
-- "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)",
ddiff --git a/examples/zone-s3/.env.example b/examples/zone-s3/.env.example
nnew file mode 100644
iindex 0000000..3955811
---- /dev/null
++++ b/examples/zone-s3/.env.example
@@@ -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
ddiff --git a/examples/zone-s3/README.md b/examples/zone-s3/README.md
nnew file mode 100644
iindex 0000000..d7ebe43
---- /dev/null
++++ b/examples/zone-s3/README.md
@@@ -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`.
ddiff --git a/examples/zone-s3/package.json b/examples/zone-s3/package.json
nnew file mode 100644
iindex 0000000..d8b702f
---- /dev/null
++++ b/examples/zone-s3/package.json
@@@ -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"
++ }
++}
ddiff --git a/examples/zone-s3/test/smoke.test.js b/examples/zone-s3/test/smoke.test.js
nnew file mode 100644
iindex 0000000..301ac8c
---- /dev/null
++++ b/examples/zone-s3/test/smoke.test.js
@@@ -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, '&amp;').replace(/</g, '&lt;');
++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); });
ddiff --git a/examples/zone-s3/zone.js b/examples/zone-s3/zone.js
nnew file mode 100644
iindex 0000000..921d8a4
---- /dev/null
++++ b/examples/zone-s3/zone.js
@@@ -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); });
++}
ddiff --git a/package-lock.json b/package-lock.json
iindex e3d03c6..cc9bd80 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/test/structure.test.js b/test/structure.test.js
iindex 29c60a4..a38c7c1 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'];
++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`);
}