openvibers/openvibe.sdk Commits
SDK 0.49.0: openvibe-sdk/download and the openvibe-download command (folder backups) (#53)
Changes
@@@ -3,6 +3,17 @@All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minorrelease may change an API and says so here.++## 0.49.0 (2026-10-15)++++- `openvibe-sdk/download`: `createDownloadClient({ key })` for OpenVibe.Download (the owner's "file storage and backup"):++ `files.list/all/get/remove`, `folders.list/create/ensurePath`, `upload({ path | data, name, folderId })` in the++ drive's parts, `usage()`, and `sync(dir, { to, delete, dryRun, exclude, onEvent })`, which keeps a local folder backed++ up (only what is new or changed: size, and sha256 when the drive has one; folders mirrored; `delete` removes drive++ copies whose local file is gone). Authenticates with a sync key from openvibe.download/sync, or a person's token;++ waits out 429s; stops on the drive's daily cap or quota with the reason.++- The `openvibe-download` command (`bin`): `sync <folder> --to <drive/path> [--delete] [--dry-run] [--exclude NAME]`,++ `ls [path]`, `usage`, with `OPENVIBE_DOWNLOAD_KEY`.++## 0.48.0 (2026-10-15)- `openvibe-sdk/play`: `createPlayClient(client)` for OpenVibe.Play's game services (the owner's "game services in the@@@ -185,6 +185,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client| `openvibe-sdk/geo` | both | `createGeoClient(client, {samples?, timeoutMs?})`: `nodes({role, region})` from Network's public node registry, `measure(nodes)` (beacon round trips, fastest first), `nearest({role, region, preferRegion})` → `{node, rtt_ms, measured}` (up/degraded nodes only; falls back to preferRegion when nothing can be measured) || `openvibe-sdk/openre` | server | `createOpenReClient(client, {baseUrl?, publicUrl?})`: OpenRestream, the streaming engine. `streams` (`byExternalRef`, `create`, `get`, `update`, `rotateKey`, `destinations`, `addDestination`), `destinations` (`test`, `start`, `stop`, `logs`), `sessions` (`get`, `playback` cached 10 s, `end`, `outputs`), `workers`, `manageUrl`; `{subject}` acts for a person (X-OV-Subject) || `openvibe-sdk/play` | server | `createPlayClient(client)`: OpenVibe.Play's achievements and leaderboards for any game — `titles.define(title, {name, url?, summary?, achievements, boards})`, `scores.post(title, {board, ref, entries})`, `achievements.unlock(title, {player, achievements})`, `players.adopt(title, guest, person)`, `players.achievements(title, player)`; public `titles.list/get` and `boards.get(title, board, {period, page})` need no token |++| `openvibe-sdk/download` | server | `createDownloadClient({ key })`: OpenVibe.Download, a person's drive — `files.list/all/get/remove`, `folders.list/create/ensurePath(path)`, `upload({ path \| data, name, folderId })` in parts, `usage()`, and `sync(dir, { to, delete, dryRun, exclude })` backups. The `openvibe-download` command: `sync <folder> --to <drive/path>`, `ls`, `usage` (`OPENVIBE_DOWNLOAD_KEY`, a sync key from openvibe.download/sync) || `openvibe-sdk/quest` | server | `createQuestClient(client)`: `appQuests.define(slug, {title, why?, steps: [{event_type, count, label}], badge: {name}, url?})`, `appQuests.list()`, `appQuests.retire(slug)` with an app token (audience `openvibe.quest`, `quest.app_quest.manage`); `eventType(projectId, name)` gives the `app.<project_key>.<name>` type to publish at Events. Badges only, never OpenCoins; production events count || `openvibe-sdk/vip` | server | `createVipClient({baseUrl?, tokenClient \| getToken, fetch?, timeoutMs?})`: `evaluate({subject, resource, owner, fallback, mode})`, `checkEntitlement({subject, creator, product, mode})`, `isMember(subject, creator)`; every failure is a denial. `createVipCache({vip, ttlMs, denyTtlMs, unavailableTtlMs})`: `entitlement`, `peekEntitlement`, `evaluate`, `invalidate`, `handleEvent` (`vip.membership.changed` and Billing's entitlement events drop a member's answers at once), `clear`, `bounds` || `openvibe-sdk/usage` | server | `usageSample(fields)` builds a `platform.usage-sample@1` record (required `id`, `idempotency_key`, `service`, `operation`, `quantity`, `unit`; `at` defaults to now ISO, `source` to `openvibe-sdk/usage`; null fields stripped; money fields never invented); `usageKey(service, ...parts)` joins an idempotency key with `:`; `validateUsageSample(record)` → `{ok, errors}` (openvibe-contracts, required lazily). `createUsageReporter({ db, service, source, table, billingUrl, tokenClient, audience, requireProject, ... })` owns one service's readings: `key(...parts)`/`sample(fields)`, and `record(t, reading)` queues the reading idempotency-keyed in the outbox inside the caller's transaction, relayed to Billing's `billing.usage.record` with backoff (a retry is never dropped; only Billing refusing the reading itself — any 4xx but 401/403/404/408/425/429, which stay pending — marks a row rejected; a 401 drops the cached token). The reporter bills whatever reading it is given: never record first-party or sandbox traffic (pass `requireProject: true` to refuse a reading without a `prj_…` project). `openvibe-sdk/govern` builds its `onUsage` record with it |@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.48.0",++ "version": "0.49.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30","deployed": false,"deployment": "none: a package released as git tags; services and apps pin a tag",@@@ -15,7 +15,7 @@"events: publish, pull and checkpoints by opaque cursor only, app events, webhook verification (v2), outbox and inbox","realtime SSE (resumes from the opaque cursor, dedupes by event_id)","media files and objects v2",-- "community, bot, search, chat, ai, jobs, tools, projects, modules, identity, openre, geo, vip, quest and play clients",++ "community, bot, search, chat, ai, jobs, tools, projects, modules, identity, openre, geo, vip, quest, play and download clients","bot (openvibe-sdk/bot): OpenVibe.Bot robots, operators, devices, streaming, commands and audit, the public kits and profiles, and pairing","per-actor limiter for services (openvibe-sdk/limits)","the OpenVibe Frame for any app",nnew file mode 100755@@@ -0,0 +1,86 @@++#!/usr/bin/env node++'use strict';++/**++ * openvibe-download — back a folder up into your OpenVibe.Download drive (openvibe-sdk/download).++ *++ * OPENVIBE_DOWNLOAD_KEY=ovdl_… openvibe-download sync ~/Documents --to Backups/laptop [--delete] [--dry-run] [--exclude NAME]…++ * openvibe-download ls [drive/path]++ * openvibe-download usage++ *++ * Make the key at https://openvibe.download/sync (it is shown once). OPENVIBE_DOWNLOAD_URL points elsewhere (tests).++ * Exit status: 0 done, 1 something failed or the sync was stopped (the drive's daily cap or quota), 2 bad usage.++ */++const { createDownloadClient } = require('../src/download');++++const USAGE = `Usage:++ openvibe-download sync <folder> --to <drive/path> [--delete] [--dry-run] [--exclude NAME]...++ openvibe-download ls [drive/path]++ openvibe-download usage++++Set OPENVIBE_DOWNLOAD_KEY to a sync key from https://openvibe.download/sync`;++++function parse(argv) {++ const out = { _: [], exclude: [] };++ for (let i = 0; i < argv.length; i++) {++ const a = argv[i];++ if (a === '--to') out.to = argv[++i];++ else if (a === '--exclude') out.exclude.push(argv[++i]);++ else if (a === '--delete') out.delete = true;++ else if (a === '--dry-run') out.dryRun = true;++ else if (a === '--quiet' || a === '-q') out.quiet = true;++ else if (a === '--help' || a === '-h') out.help = true;++ else if (a.startsWith('--')) throw new Error(`unknown option ${a}`);++ else out._.push(a);++ }++ return out;++}++++const mb = (n) => `${(n / (1024 * 1024)).toFixed(n < 10 * 1024 * 1024 ? 1 : 0)} MB`;++++async function main(argv, env = process.env, out = process.stdout, err = process.stderr) {++ let args;++ try { args = parse(argv); } catch (e) { err.write(`${e.message}\n\n${USAGE}\n`); return 2; }++ const [command, target] = args._;++ if (args.help || !command) { out.write(`${USAGE}\n`); return command || args.help ? 0 : 2; }++ const key = env.OPENVIBE_DOWNLOAD_KEY;++ if (!key) { err.write(`OPENVIBE_DOWNLOAD_KEY is not set.\n\n${USAGE}\n`); return 2; }++ const dl = createDownloadClient({ key, baseUrl: env.OPENVIBE_DOWNLOAD_URL || undefined });++ try {++ if (command === 'usage') {++ const u = await dl.usage();++ out.write(`${mb(u.bytes_used)} of ${mb(u.bytes_limit)} stored, ${u.files} files; ${u.uploads_today} uploads today\n`);++ return 0;++ }++ if (command === 'ls') {++ const folderId = target ? await dl.folders.ensurePath(target) : null;++ const page = await dl.files.list({ folderId });++ for (const f of page.folders) out.write(`${f.name}/\n`);++ for (const f of page.files) out.write(`${f.name}\t${mb(f.size)}\n`);++ return 0;++ }++ if (command === 'sync') {++ if (!target || !args.to) { err.write(`sync needs a folder and --to <drive/path>.\n\n${USAGE}\n`); return 2; }++ const report = await dl.sync(target, {++ to: args.to, delete: Boolean(args.delete), dryRun: Boolean(args.dryRun), exclude: args.exclude,++ onEvent: (e) => {++ if (args.quiet && e.type !== 'error') return;++ if (e.type === 'upload' || e.type === 'replace') out.write(`${args.dryRun ? 'would send' : e.type === 'replace' ? 'update' : 'send'} ${e.path} (${mb(e.size)})\n`);++ else if (e.type === 'delete') out.write(`${args.dryRun ? 'would delete' : 'delete'} ${e.path}\n`);++ else if (e.type === 'error') err.write(`failed ${e.path}: ${e.message}\n`);++ },++ });++ out.write(`${args.dryRun ? 'Dry run: ' : ''}${report.uploaded} new, ${report.replaced} updated, ${report.skipped} unchanged, ${report.deleted} deleted, ${report.failed} failed (${mb(report.bytes)})\n`);++ if (report.stopped) err.write(`Stopped: ${report.stopped.message}\n`);++ return report.failed || report.stopped ? 1 : 0;++ }++ err.write(`unknown command ${command}\n\n${USAGE}\n`);++ return 2;++ } catch (e) {++ err.write(`${e.detail || e.message || e}\n`);++ return 1;++ }++}++++if (require.main === module) main(process.argv.slice(2)).then((code) => { process.exitCode = code; });++++module.exports = { main, parse };@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.48.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.49.0, MIT). Do not edit.// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity./* eslint-disable */@@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);const semver = __load(6);/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.48.0';++const SDK_VERSION = '0.49.0';module.exports = {createClient,nnew file mode 100644@@@ -0,0 +1,7 @@++// Generated by scripts/esm.js from src/download.js. Do not edit.++import cjs from '../src/download.js';++++export const {++ createDownloadClient, contentTypeOf,++} = cjs;++export default cjs;@@@ -8,6 +8,6 @@ export const {registry, identity, modules, events, realtime, media,community, bot, search, chat, ai, jobs,tools, projects, resources, vip, quest, play,-- openre, geo, usage, commerce, telemetry,++ download, openre, geo, usage, commerce, telemetry,} = cjs;export default cjs;@@@ -27,6 +27,7 @@ module.exports = {vip: require('./src/vip'),quest: require('./src/quest'),play: require('./src/play'),++ download: require('./src/download'),openre: require('./src/openre'),geo: require('./src/geo'),usage: require('./src/usage'),@@@ -1,13 +1,16 @@{"name": "openvibe-sdk",-- "version": "0.48.0",++ "version": "0.49.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.48.0",++ "version": "0.49.0","license": "MIT",++ "bin": {++ "openvibe-download": "bin/openvibe-download.js"++ },"devDependencies": {"@electric-sql/pglite": "^0.5.8","iovalkey": "^0.4.0",@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.48.0",++ "version": "0.49.0","description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.","main": "index.js","types": "types/index.d.ts",@@@ -242,6 +242,12 @@"import": "./esm/play.mjs","default": "./src/play.js"},++ "./download": {++ "types": "./types/download.d.ts",++ "browser": null,++ "import": "./esm/download.mjs",++ "default": "./src/download.js"++ },"./quest": {"types": "./types/quest.d.ts","browser": null,@@@ -272,7 +278,11 @@},"./package.json": "./package.json"},++ "bin": {++ "openvibe-download": "bin/openvibe-download.js"++ },"files": [++ "bin/","index.js","browser.js","src/",@@@ -53,6 +53,7 @@ const ENTRIES = {'geo.mjs': '../src/geo.js','openre.mjs': '../src/openre.js','play.mjs': '../src/play.js',++ 'download.mjs': '../src/download.js','quest.mjs': '../src/quest.js','vip.mjs': '../src/vip.js','frame.mjs': '../src/frame.js',@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');const semver = require('./semver');/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.48.0';++const SDK_VERSION = '0.49.0';module.exports = {createClient,nnew file mode 100644@@@ -0,0 +1,269 @@++'use strict';++/**++ * openvibe-sdk/download — OpenVibe.Download, a person's drive at openvibe.download, from a script or the++ * `openvibe-download` command: list it, make folders, upload in parts, delete, and `sync()`, which keeps a folder on++ * this computer backed up in the drive. Server-side (Node): it reads files from disk.++ *++ * It authenticates with a sync key the person makes at https://openvibe.download/sync (`ovdl_…`, shown once; it acts++ * on their drive and nothing else), or with the person's own Network token.++ *++ * const dl = createDownloadClient({ key: process.env.OPENVIBE_DOWNLOAD_KEY });++ * await dl.files.list({ folderId }) // → { folder_id, folders, files, usage }++ * const folderId = await dl.folders.ensurePath('Backups/laptop');++ * await dl.upload({ path: './notes.txt', folderId }); // → the file (or { data, name } for bytes)++ * const report = await dl.sync('/home/kim/Documents', { to: 'Backups/laptop' });++ * // report: { uploaded, skipped, replaced, deleted, failed, bytes, errors }++ *++ * sync(dir, { to, delete, dryRun, exclude, onEvent }): walks `dir` (symbolic links are not followed), mirrors its++ * folders under `to`, and uploads a file whose drive copy is missing or differs (size, or sha256 when the drive has++ * one). A changed file is uploaded again and the older copy deleted. With `delete: true` drive files under `to` that++ * have no local copy are removed (a folder whose local folder is gone is emptied); without it a backup only adds. `exclude` lists names to skip++ * (exact, or `*.ext`); `.DS_Store`, `Thumbs.db` and `desktop.ini` are always skipped. A rate limit (429) is waited++ * out and retried; the drive's daily file cap or storage quota stops the sync with the reason.++ */++const fs = require('fs');++const fsp = require('fs/promises');++const path = require('path');++const crypto = require('crypto');++const { createClient } = require('./core/client');++const { isOpenVibeError } = require('./core/errors');++++const DEFAULT_URL = 'https://openvibe.download';++const ALWAYS_SKIP = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini']);++const STOPPING = new Set(['file.daily_limit', 'file.quota', 'sync_key.invalid', 'token.required']);++const CONTENT_TYPES = {++ '.txt': 'text/plain', '.md': 'text/plain', '.csv': 'text/csv', '.json': 'application/json', '.pdf': 'application/pdf',++ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',++ '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.mp4': 'video/mp4', '.webm': 'video/webm', '.zip': 'application/zip',++};++++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));++++function contentTypeOf(name) {++ return CONTENT_TYPES[path.extname(String(name)).toLowerCase()] || 'application/octet-stream';++}++++async function sha256File(file) {++ const hash = crypto.createHash('sha256');++ await new Promise((resolve, reject) => {++ fs.createReadStream(file).on('data', (c) => hash.update(c)).on('end', resolve).on('error', reject);++ });++ return hash.digest('hex');++}++++function excluded(name, patterns) {++ if (ALWAYS_SKIP.has(name)) return true;++ return patterns.some((p) => (p.startsWith('*.') ? name.toLowerCase().endsWith(p.slice(1).toLowerCase()) : name === p));++}++++function createDownloadClient(options = {}) {++ const baseUrl = String(options.baseUrl || DEFAULT_URL).replace(/\/+$/, '');++ const token = options.key || options.token;++ if (!token) throw new TypeError('createDownloadClient: pass { key } (a sync key from openvibe.download/sync) or { token }');++ const client = options.client || createClient({ token, baseUrls: { download: baseUrl }, retries: 2 });++ const wait = options.sleep || sleep;++++ /** One call, a rate limit waited out (Retry-After, else a minute) up to `tries` times. */++ async function call(opts, tries = 6) {++ for (let attempt = 1; ; attempt++) {++ try {++ return await client.request({ service: 'download', baseUrl, ...opts });++ } catch (err) {++ if (!isOpenVibeError(err) || err.status !== 429 || attempt >= tries || (err.code && err.code !== 'rate_limited')) throw err;++ const seconds = Number(err.problem && (err.problem.retry_after_seconds || err.problem.retry_after)) || 60;++ await wait(Math.min(300, Math.max(1, seconds)) * 1000);++ }++ }++ }++ const json = async (opts) => (await call(opts)).data;++++ const files = {++ /** One folder's files and subfolders (or the root's), up to 200 a page; `before` pages back. */++ list: ({ folderId = null, limit = 200, before = null } = {}) => json({++ path: '/api/v1/files', query: { folder_id: folderId || undefined, limit, before: before || undefined },++ }),++ /** Every file in one folder, all pages. */++ async all({ folderId = null } = {}) {++ const out = [];++ let before = null;++ for (;;) {++ const page = await files.list({ folderId, before });++ out.push(...page.files);++ if (page.files.length < 200) return out;++ before = page.files[page.files.length - 1].id;++ }++ },++ get: (id) => json({ path: `/api/v1/files/${encodeURIComponent(id)}` }),++ remove: (id) => call({ method: 'DELETE', path: `/api/v1/files/${encodeURIComponent(id)}`, idempotencyKey: false }),++ };++++ const folders = {++ list: async () => (await json({ path: '/api/v1/folders' })).folders,++ create: async (name, parentId = null) => (await json({++ method: 'POST', path: '/api/v1/folders', json: { name, parent_id: parentId }, idempotencyKey: false,++ })).folder,++ /** The id of the folder at `drivePath` ('Backups/laptop'), making the missing ones; null for the root. */++ async ensurePath(drivePath, { known = null } = {}) {++ const parts = String(drivePath || '').split('/').map((p) => p.trim()).filter(Boolean);++ const all = known || await folders.list();++ let parent = null;++ for (const name of parts) {++ let found = all.find((f) => (f.parent_id || null) === parent && f.name.toLowerCase() === name.toLowerCase());++ if (!found) {++ found = await folders.create(name, parent);++ all.push(found);++ }++ parent = found.id;++ }++ return parent;++ },++ };++++ /** Upload one file: { path } from disk, or { data, name }; → the drive's file. */++ async function upload({ path: file = null, data = null, name = null, folderId = null, contentType = null } = {}) {++ const fileName = name || (file ? path.basename(file) : null);++ if (!fileName) throw new TypeError('upload: pass { path } or { data, name }');++ const size = file ? (await fsp.stat(file)).size : Buffer.byteLength(data);++ if (size < 1) throw new TypeError(`upload: ${fileName} is empty (the drive keeps files of at least one byte)`);++ const started = await json({++ method: 'POST', path: '/api/v1/files', idempotencyKey: false,++ json: { name: fileName, size, content_type: contentType || contentTypeOf(fileName), folder_id: folderId },++ });++ const up = started.upload;++ const handle = file ? await fsp.open(file, 'r') : null;++ try {++ for (let n = 1; n <= up.parts_expected; n++) {++ const start = (n - 1) * up.part_size;++ const length = Math.min(up.part_size, size - start);++ let body;++ if (handle) {++ body = Buffer.alloc(length);++ await handle.read(body, 0, length, start);++ } else {++ body = Buffer.from(data).subarray(start, start + length);++ }++ await call({ method: 'PUT', path: `/api/v1/uploads/${up.id}/parts/${n}`, body, headers: { 'content-type': 'application/octet-stream' } });++ }++ } catch (err) {++ await call({ method: 'DELETE', path: `/api/v1/uploads/${up.id}`, idempotencyKey: false }).catch(() => {});++ throw err;++ } finally {++ if (handle) await handle.close();++ }++ return (await json({ method: 'POST', path: `/api/v1/uploads/${up.id}/complete`, json: {}, idempotencyKey: false })).file;++ }++++ const usage = async () => (await json({ path: '/api/v1/usage' })).usage;++++ /** Keep `dir` backed up under the drive folder `to` (see the module comment). */++ async function sync(dir, { to = '', delete: prune = false, dryRun = false, exclude = [], onEvent = () => {} } = {}) {++ const root = path.resolve(dir);++ const stat = await fsp.stat(root);++ if (!stat.isDirectory()) throw new TypeError(`sync: ${root} is not a folder`);++ const report = { uploaded: 0, replaced: 0, skipped: 0, deleted: 0, failed: 0, bytes: 0, errors: [] };++ const allFolders = await folders.list();++ const emit = (e) => { try { onEvent(e); } catch { /* the caller's printer must not stop the sync */ } };++ let stopped = null;++++ const childFolder = (parentId, name) => allFolders.find((f) => (f.parent_id || null) === parentId && f.name.toLowerCase() === name.toLowerCase());++++ async function walk(localDir, folderId, rel) {++ if (stopped) return;++ const entries = (await fsp.readdir(localDir, { withFileTypes: true }))++ .filter((e) => !excluded(e.name, exclude))++ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));++ const remote = folderId === undefined ? [] : await files.all({ folderId });++ const byName = new Map();++ for (const f of remote) {++ const list = byName.get(f.name) || [];++ list.push(f);++ byName.set(f.name, list);++ }++ const seenFiles = new Set();++ const seenDirs = new Set();++ for (const e of entries) {++ if (stopped) return;++ const local = path.join(localDir, e.name);++ const relPath = rel ? `${rel}/${e.name}` : e.name;++ if (e.isSymbolicLink()) { report.skipped++; emit({ type: 'skip', path: relPath, reason: 'symbolic link' }); continue; }++ if (e.isDirectory()) {++ seenDirs.add(e.name.toLowerCase());++ let sub = folderId === undefined ? undefined : childFolder(folderId, e.name);++ if (!sub && !dryRun) {++ sub = await folders.create(e.name, folderId);++ allFolders.push(sub);++ emit({ type: 'folder', path: relPath });++ }++ await walk(local, sub ? sub.id : undefined, relPath);++ continue;++ }++ if (!e.isFile()) continue;++ seenFiles.add(e.name);++ const st = await fsp.stat(local);++ if (st.size < 1) { report.skipped++; emit({ type: 'skip', path: relPath, reason: 'empty' }); continue; }++ const copies = (byName.get(e.name) || []).sort((a, b) => (a.created_at < b.created_at ? 1 : -1));++ const current = copies[0] || null;++ let same = false;++ if (current && Number(current.size) === st.size) {++ same = current.sha256 ? current.sha256 === await sha256File(local) : true;++ }++ if (same) { report.skipped++; emit({ type: 'same', path: relPath }); continue; }++ emit({ type: current ? 'replace' : 'upload', path: relPath, size: st.size, dryRun });++ if (dryRun) { report[current ? 'replaced' : 'uploaded']++; report.bytes += st.size; continue; }++ try {++ await upload({ path: local, folderId });++ report[current ? 'replaced' : 'uploaded']++;++ report.bytes += st.size;++ for (const old of copies) await files.remove(old.id);++ } catch (err) {++ report.failed++;++ const code = isOpenVibeError(err) ? err.code : null;++ report.errors.push({ path: relPath, code, message: String(err.detail || err.message || err) });++ emit({ type: 'error', path: relPath, code, message: String(err.detail || err.message || err) });++ if (code && STOPPING.has(code)) stopped = { code, message: String(err.detail || err.message) };++ }++ }++ if (!prune || folderId === undefined) return;++ for (const [name, list] of byName) {++ if (seenFiles.has(name)) continue;++ for (const f of list) {++ emit({ type: 'delete', path: rel ? `${rel}/${name}` : name, dryRun });++ if (!dryRun) await files.remove(f.id);++ report.deleted++;++ }++ }++ // A drive folder whose local folder is gone: its files go (the folder itself stays, empty).++ for (const sub of allFolders.filter((f) => (f.parent_id || null) === folderId && !seenDirs.has(f.name.toLowerCase()))) {++ await empty(sub.id, rel ? `${rel}/${sub.name}` : sub.name);++ }++ }++++ async function empty(folderId, rel) {++ for (const f of await files.all({ folderId })) {++ emit({ type: 'delete', path: `${rel}/${f.name}`, dryRun });++ if (!dryRun) await files.remove(f.id);++ report.deleted++;++ }++ for (const sub of allFolders.filter((f) => f.parent_id === folderId)) await empty(sub.id, `${rel}/${sub.name}`);++ }++++ const top = dryRun++ ? (await (async () => {++ let parent = null;++ for (const name of String(to).split('/').map((p) => p.trim()).filter(Boolean)) {++ const f = childFolder(parent, name);++ if (!f) return undefined;++ parent = f.id;++ }++ return parent;++ })())++ : await folders.ensurePath(to, { known: allFolders });++ await walk(root, top, '');++ if (stopped) report.stopped = stopped;++ return report;++ }++++ return { files, folders, upload, usage, sync };++}++++module.exports = { createDownloadClient, contentTypeOf };@@@ -76,7 +76,7 @@ run([for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);}assert.deepEqual(problems, []);-- assert.deepEqual(blocked.sort(), ['./account-data', './cache', './commerce', './cost', './db', './delivery', './events', './govern', './identity', './limits', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './runtime', './service', './sso', './storage', './telemetry', './testing', './usage', './valkey', './vip']);++ assert.deepEqual(blocked.sort(), ['./account-data', './cache', './commerce', './cost', './db', './delivery', './download', './events', './govern', './identity', './limits', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './runtime', './service', './sso', './storage', './telemetry', './testing', './usage', './valkey', './vip']);const rel = [...scanned].map((f) => path.relative(ROOT, f));for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {assert.ok(rel.includes(must), `${must} is scanned`);nnew file mode 100644@@@ -0,0 +1,170 @@++'use strict';++// openvibe-sdk/download: OpenVibe.Download's drive API from a script, and sync() backups, against a small stand-in++// drive (the routes, the parts and the sha256 the real one keeps). Checks: folders mirrored, only new or changed files++// sent, a changed file replaced, --delete, a dry run that writes nothing, 429 waited out, the daily cap stopping the++// sync, and the openvibe-download command's exit codes.++const assert = require('assert');++const crypto = require('crypto');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const { createDownloadClient } = require('../src/download');++const { main } = require('../bin/openvibe-download');++++const KEY = `ovdl_${'k'.repeat(43)}`;++++function startDrive({ dailyCap = 1000 } = {}) {++ const state = { folders: [], files: [], uploads: new Map(), seq: 0, uploadsToday: 0, throttleNext: false, requests: [] };++ const id = (p) => `${p}_${String(++state.seq).padStart(26, '0')}`;++ const send = (res, status, body) => {++ res.writeHead(status, { 'content-type': status >= 400 ? 'application/problem+json' : 'application/json' });++ res.end(body === undefined ? '' : JSON.stringify(body));++ };++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const url = new URL(req.url, 'http://drive.test');++ const body = Buffer.concat(chunks);++ state.requests.push(`${req.method} ${url.pathname}`);++ if (req.headers.authorization !== `Bearer ${KEY}`) return send(res, 401, { code: 'sync_key.invalid', detail: 'bad key' });++ const json = () => (body.length ? JSON.parse(body.toString('utf8')) : {});++ const p = url.pathname;++ if (req.method === 'GET' && p === '/api/v1/folders') return send(res, 200, { folders: state.folders });++ if (req.method === 'POST' && p === '/api/v1/folders') {++ const b = json();++ const f = { id: id('fld'), name: b.name, parent_id: b.parent_id || null, created_at: new Date().toISOString() };++ state.folders.push(f);++ return send(res, 201, { folder: f });++ }++ if (req.method === 'GET' && p === '/api/v1/files') {++ const folderId = url.searchParams.get('folder_id') || null;++ return send(res, 200, {++ folder_id: folderId,++ folders: state.folders.filter((f) => f.parent_id === folderId),++ files: state.files.filter((f) => f.folder_id === folderId),++ usage: { bytes_used: 0, bytes_limit: 1, files: state.files.length, uploads_today: state.uploadsToday },++ });++ }++ if (req.method === 'POST' && p === '/api/v1/files') {++ if (state.throttleNext) {++ state.throttleNext = false;++ return send(res, 429, { code: 'rate_limited', detail: 'slow down', retry_after_seconds: 1 });++ }++ if (state.uploadsToday >= dailyCap) return send(res, 429, { code: 'file.daily_limit', detail: 'You have uploaded enough files today.' });++ state.uploadsToday++;++ const b = json();++ const up = { id: id('upl'), name: b.name, size: b.size, folder_id: b.folder_id || null, part_size: 4, parts_expected: Math.ceil(b.size / 4), parts: [] };++ state.uploads.set(up.id, up);++ return send(res, 201, { upload: { id: up.id, part_size: up.part_size, parts_expected: up.parts_expected, missing: [] } });++ }++ let m = /^\/api\/v1\/uploads\/([^/]+)\/parts\/(\d+)$/.exec(p);++ if (req.method === 'PUT' && m) {++ state.uploads.get(m[1]).parts[Number(m[2]) - 1] = body;++ return send(res, 200, { part: Number(m[2]), size: body.length });++ }++ m = /^\/api\/v1\/uploads\/([^/]+)\/complete$/.exec(p);++ if (req.method === 'POST' && m) {++ const up = state.uploads.get(m[1]);++ const data = Buffer.concat(up.parts);++ const file = { id: id('fil'), name: up.name, size: data.length, sha256: crypto.createHash('sha256').update(data).digest('hex'), folder_id: up.folder_id, created_at: new Date(Date.now() + state.seq).toISOString(), data };++ state.files.push(file);++ const { data: _d, ...wire } = file;++ return send(res, 201, { file: wire });++ }++ m = /^\/api\/v1\/files\/([^/]+)$/.exec(p);++ if (req.method === 'DELETE' && m) {++ state.files = state.files.filter((f) => f.id !== m[1]);++ return send(res, 204);++ }++ if (req.method === 'GET' && p === '/api/v1/usage') return send(res, 200, { usage: { bytes_used: 10, bytes_limit: 100, files: state.files.length, uploads_today: state.uploadsToday } });++ return send(res, 404, { code: 'route.not_found' });++ });++ });++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ state, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) })));++}++++function tree(files) {++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ovdl-'));++ for (const [rel, text] of Object.entries(files)) {++ fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });++ fs.writeFileSync(path.join(dir, rel), text);++ }++ return dir;++}++++(async () => {++ const drive = await startDrive();++ const noWait = async () => {};++ const dl = createDownloadClient({ key: KEY, baseUrl: drive.url, sleep: noWait });++ const local = tree({ 'notes.txt': 'hello world', 'photos/cat.jpg': 'meow meow', 'photos/.DS_Store': 'x', 'empty.txt': '' });++ try {++ // A dry run writes nothing.++ const dry = await dl.sync(local, { to: 'Backups/laptop', dryRun: true });++ assert.deepStrictEqual([dry.uploaded, drive.state.files.length, drive.state.folders.length], [2, 0, 0], 'a dry run sends and makes nothing');++++ // The first sync mirrors the folders and sends both files (the empty file and .DS_Store are skipped), and a++ // rate limit on the way is waited out.++ drive.state.throttleNext = true;++ const first = await dl.sync(local, { to: 'Backups/laptop' });++ assert.strictEqual(first.uploaded, 2, JSON.stringify(first));++ assert.strictEqual(first.failed, 0);++ const names = drive.state.folders.map((f) => f.name).sort();++ assert.deepStrictEqual(names, ['Backups', 'laptop', 'photos']);++ const cat = drive.state.files.find((f) => f.name === 'cat.jpg');++ assert.strictEqual(cat.data.toString(), 'meow meow', 'parts arrive in order');++ assert.ok(!drive.state.files.some((f) => f.name === '.DS_Store' || f.name === 'empty.txt'));++++ // Unchanged: nothing sent. Changed: sent again and the older copy deleted.++ const again = await dl.sync(local, { to: 'Backups/laptop' });++ assert.deepStrictEqual([again.uploaded, again.replaced, again.skipped >= 2], [0, 0, true]);++ fs.writeFileSync(path.join(local, 'notes.txt'), 'hello there, world');++ const changed = await dl.sync(local, { to: 'Backups/laptop' });++ assert.strictEqual(changed.replaced, 1);++ const notes = drive.state.files.filter((f) => f.name === 'notes.txt');++ assert.strictEqual(notes.length, 1, 'the older copy is gone');++ assert.strictEqual(notes[0].data.toString(), 'hello there, world');++++ // --delete removes drive files whose local copy is gone, including inside a vanished folder.++ fs.rmSync(path.join(local, 'photos'), { recursive: true });++ const kept = await dl.sync(local, { to: 'Backups/laptop' });++ assert.strictEqual(kept.deleted, 0, 'without --delete a backup only adds');++ const pruned = await dl.sync(local, { to: 'Backups/laptop', delete: true });++ assert.strictEqual(pruned.deleted, 1);++ assert.ok(!drive.state.files.some((f) => f.name === 'cat.jpg'));++++ // The command: usage, ls, a missing key, a bad folder.++ const out = [];++ const err = [];++ const w = (arr) => ({ write: (s) => { arr.push(s); return true; } });++ const env = { OPENVIBE_DOWNLOAD_KEY: KEY, OPENVIBE_DOWNLOAD_URL: drive.url };++ assert.strictEqual(await main(['usage'], env, w(out), w(err)), 0);++ assert.match(out.join(''), /stored/);++ assert.strictEqual(await main(['ls', 'Backups/laptop'], env, w(out), w(err)), 0);++ assert.match(out.join(''), /notes\.txt/);++ assert.strictEqual(await main(['sync', local], env, w(out), w(err)), 2, 'sync needs --to');++ assert.strictEqual(await main(['usage'], {}, w(out), w(err)), 2, 'no key');++ assert.strictEqual(await main(['sync', local, '--to', 'B', '--bogus'], env, w(out), w(err)), 2);++ } finally {++ await drive.close();++ }++++ // The drive's daily cap stops the sync with its reason, and the command exits 1.++ const capped = await startDrive({ dailyCap: 1 });++ try {++ const dir = tree({ 'a.txt': 'aaaa', 'b.txt': 'bbbb', 'c.txt': 'cccc' });++ const report = await createDownloadClient({ key: KEY, baseUrl: capped.url, sleep: noWait }).sync(dir, { to: 'B' });++ assert.strictEqual(report.uploaded, 1);++ assert.strictEqual(report.stopped && report.stopped.code, 'file.daily_limit');++ assert.strictEqual(report.failed, 1, 'it stops at the first refusal');++ const out = [];++ const code = await main(['sync', dir, '--to', 'B', '-q'], { OPENVIBE_DOWNLOAD_KEY: KEY, OPENVIBE_DOWNLOAD_URL: capped.url }, { write: (s) => out.push(s) }, { write: (s) => out.push(s) });++ assert.strictEqual(code, 1);++ assert.match(out.join(''), /Stopped: You have uploaded enough files today/);++ } finally {++ await capped.close();++ }++ assert.throws(() => createDownloadClient({}), /sync key/);++ console.log('download: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });@@@ -66,7 +66,7 @@ run([})().catch((err) => { console.error(err); process.exit(1); });`;const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));-- assert.equal(subpaths.length, 46);++ assert.equal(subpaths.length, 47);assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');} finally {fs.rmSync(tmp, { recursive: true, force: true });nnew file mode 100644@@@ -0,0 +1,28 @@++/** openvibe-sdk/download: OpenVibe.Download, a person's drive — list, folders, uploads in parts, and sync() backups. */++export interface DriveFile { id: string; name: string; size: number; content_type: string; sha256: string | null; folder_id: string | null; created_at: string }++export interface DriveFolder { id: string; name: string; parent_id: string | null; created_at: string }++export interface DriveUsage { bytes_used: number; bytes_limit: number; max_file_bytes: number; files: number; uploads_today: number; uploads_per_day: number }++export interface SyncEvent { type: 'upload' | 'replace' | 'same' | 'skip' | 'folder' | 'delete' | 'error'; path: string; size?: number; reason?: string; code?: string | null; message?: string; dryRun?: boolean }++export interface SyncReport {++ uploaded: number; replaced: number; skipped: number; deleted: number; failed: number; bytes: number;++ errors: { path: string; code: string | null; message: string }[];++ stopped?: { code: string; message: string };++}++export interface DownloadClient {++ files: {++ list(opts?: { folderId?: string | null; limit?: number; before?: string | null }): Promise<{ folder_id: string | null; folders: DriveFolder[]; files: DriveFile[]; usage: DriveUsage }>;++ all(opts?: { folderId?: string | null }): Promise<DriveFile[]>;++ get(id: string): Promise<{ file: DriveFile }>;++ remove(id: string): Promise<unknown>;++ };++ folders: {++ list(): Promise<DriveFolder[]>;++ create(name: string, parentId?: string | null): Promise<DriveFolder>;++ ensurePath(drivePath: string, opts?: { known?: DriveFolder[] }): Promise<string | null>;++ };++ upload(input: { path?: string; data?: Uint8Array | string; name?: string; folderId?: string | null; contentType?: string }): Promise<DriveFile>;++ usage(): Promise<DriveUsage>;++ sync(dir: string, opts?: { to?: string; delete?: boolean; dryRun?: boolean; exclude?: string[]; onEvent?: (e: SyncEvent) => void }): Promise<SyncReport>;++}++export function createDownloadClient(options: { key?: string; token?: string; baseUrl?: string; client?: unknown; sleep?: (ms: number) => Promise<void> }): DownloadClient;++export function contentTypeOf(name: string): string;@@@ -18,6 +18,7 @@ export * as resources from './resources';export * as vip from './vip';export * as quest from './quest';export * as play from './play';++export * as download from './download';export * as openre from './openre';export * as geo from './geo';export * as usage from './usage';
@@ -3,6 +3,17 @@
@@ -3,6 +3,17 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
release may change an API and says so here.
+## 0.49.0 (2026-10-15)
+
+- `openvibe-sdk/download`: `createDownloadClient({ key })` for OpenVibe.Download (the owner's "file storage and backup"):
+ `files.list/all/get/remove`, `folders.list/create/ensurePath`, `upload({ path | data, name, folderId })` in the
+ drive's parts, `usage()`, and `sync(dir, { to, delete, dryRun, exclude, onEvent })`, which keeps a local folder backed
+ up (only what is new or changed: size, and sha256 when the drive has one; folders mirrored; `delete` removes drive
+ copies whose local file is gone). Authenticates with a sync key from openvibe.download/sync, or a person's token;
+ waits out 429s; stops on the drive's daily cap or quota with the reason.
+- The `openvibe-download` command (`bin`): `sync <folder> --to <drive/path> [--delete] [--dry-run] [--exclude NAME]`,
+ `ls [path]`, `usage`, with `OPENVIBE_DOWNLOAD_KEY`.
+
## 0.48.0 (2026-10-15)
## 0.48.0 (2026-10-15)
- `openvibe-sdk/play`: `createPlayClient(client)` for OpenVibe.Play's game services (the owner's "game services in the
- `openvibe-sdk/play`: `createPlayClient(client)` for OpenVibe.Play's game services (the owner's "game services in the
@@ -185,6 +185,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
@@ -185,6 +185,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/geo` | both | `createGeoClient(client, {samples?, timeoutMs?})`: `nodes({role, region})` from Network's public node registry, `measure(nodes)` (beacon round trips, fastest first), `nearest({role, region, preferRegion})` → `{node, rtt_ms, measured}` (up/degraded nodes only; falls back to preferRegion when nothing can be measured) |
| `openvibe-sdk/geo` | both | `createGeoClient(client, {samples?, timeoutMs?})`: `nodes({role, region})` from Network's public node registry, `measure(nodes)` (beacon round trips, fastest first), `nearest({role, region, preferRegion})` → `{node, rtt_ms, measured}` (up/degraded nodes only; falls back to preferRegion when nothing can be measured) |
| `openvibe-sdk/openre` | server | `createOpenReClient(client, {baseUrl?, publicUrl?})`: OpenRestream, the streaming engine. `streams` (`byExternalRef`, `create`, `get`, `update`, `rotateKey`, `destinations`, `addDestination`), `destinations` (`test`, `start`, `stop`, `logs`), `sessions` (`get`, `playback` cached 10 s, `end`, `outputs`), `workers`, `manageUrl`; `{subject}` acts for a person (X-OV-Subject) |
| `openvibe-sdk/openre` | server | `createOpenReClient(client, {baseUrl?, publicUrl?})`: OpenRestream, the streaming engine. `streams` (`byExternalRef`, `create`, `get`, `update`, `rotateKey`, `destinations`, `addDestination`), `destinations` (`test`, `start`, `stop`, `logs`), `sessions` (`get`, `playback` cached 10 s, `end`, `outputs`), `workers`, `manageUrl`; `{subject}` acts for a person (X-OV-Subject) |
| `openvibe-sdk/play` | server | `createPlayClient(client)`: OpenVibe.Play's achievements and leaderboards for any game — `titles.define(title, {name, url?, summary?, achievements, boards})`, `scores.post(title, {board, ref, entries})`, `achievements.unlock(title, {player, achievements})`, `players.adopt(title, guest, person)`, `players.achievements(title, player)`; public `titles.list/get` and `boards.get(title, board, {period, page})` need no token |
| `openvibe-sdk/play` | server | `createPlayClient(client)`: OpenVibe.Play's achievements and leaderboards for any game — `titles.define(title, {name, url?, summary?, achievements, boards})`, `scores.post(title, {board, ref, entries})`, `achievements.unlock(title, {player, achievements})`, `players.adopt(title, guest, person)`, `players.achievements(title, player)`; public `titles.list/get` and `boards.get(title, board, {period, page})` need no token |
+| `openvibe-sdk/download` | server | `createDownloadClient({ key })`: OpenVibe.Download, a person's drive — `files.list/all/get/remove`, `folders.list/create/ensurePath(path)`, `upload({ path \| data, name, folderId })` in parts, `usage()`, and `sync(dir, { to, delete, dryRun, exclude })` backups. The `openvibe-download` command: `sync <folder> --to <drive/path>`, `ls`, `usage` (`OPENVIBE_DOWNLOAD_KEY`, a sync key from openvibe.download/sync) |
| `openvibe-sdk/quest` | server | `createQuestClient(client)`: `appQuests.define(slug, {title, why?, steps: [{event_type, count, label}], badge: {name}, url?})`, `appQuests.list()`, `appQuests.retire(slug)` with an app token (audience `openvibe.quest`, `quest.app_quest.manage`); `eventType(projectId, name)` gives the `app.<project_key>.<name>` type to publish at Events. Badges only, never OpenCoins; production events count |
| `openvibe-sdk/quest` | server | `createQuestClient(client)`: `appQuests.define(slug, {title, why?, steps: [{event_type, count, label}], badge: {name}, url?})`, `appQuests.list()`, `appQuests.retire(slug)` with an app token (audience `openvibe.quest`, `quest.app_quest.manage`); `eventType(projectId, name)` gives the `app.<project_key>.<name>` type to publish at Events. Badges only, never OpenCoins; production events count |
| `openvibe-sdk/vip` | server | `createVipClient({baseUrl?, tokenClient \| getToken, fetch?, timeoutMs?})`: `evaluate({subject, resource, owner, fallback, mode})`, `checkEntitlement({subject, creator, product, mode})`, `isMember(subject, creator)`; every failure is a denial. `createVipCache({vip, ttlMs, denyTtlMs, unavailableTtlMs})`: `entitlement`, `peekEntitlement`, `evaluate`, `invalidate`, `handleEvent` (`vip.membership.changed` and Billing's entitlement events drop a member's answers at once), `clear`, `bounds` |
| `openvibe-sdk/vip` | server | `createVipClient({baseUrl?, tokenClient \| getToken, fetch?, timeoutMs?})`: `evaluate({subject, resource, owner, fallback, mode})`, `checkEntitlement({subject, creator, product, mode})`, `isMember(subject, creator)`; every failure is a denial. `createVipCache({vip, ttlMs, denyTtlMs, unavailableTtlMs})`: `entitlement`, `peekEntitlement`, `evaluate`, `invalidate`, `handleEvent` (`vip.membership.changed` and Billing's entitlement events drop a member's answers at once), `clear`, `bounds` |
| `openvibe-sdk/usage` | server | `usageSample(fields)` builds a `platform.usage-sample@1` record (required `id`, `idempotency_key`, `service`, `operation`, `quantity`, `unit`; `at` defaults to now ISO, `source` to `openvibe-sdk/usage`; null fields stripped; money fields never invented); `usageKey(service, ...parts)` joins an idempotency key with `:`; `validateUsageSample(record)` → `{ok, errors}` (openvibe-contracts, required lazily). `createUsageReporter({ db, service, source, table, billingUrl, tokenClient, audience, requireProject, ... })` owns one service's readings: `key(...parts)`/`sample(fields)`, and `record(t, reading)` queues the reading idempotency-keyed in the outbox inside the caller's transaction, relayed to Billing's `billing.usage.record` with backoff (a retry is never dropped; only Billing refusing the reading itself — any 4xx but 401/403/404/408/425/429, which stay pending — marks a row rejected; a 401 drops the cached token). The reporter bills whatever reading it is given: never record first-party or sandbox traffic (pass `requireProject: true` to refuse a reading without a `prj_…` project). `openvibe-sdk/govern` builds its `onUsage` record with it |
| `openvibe-sdk/usage` | server | `usageSample(fields)` builds a `platform.usage-sample@1` record (required `id`, `idempotency_key`, `service`, `operation`, `quantity`, `unit`; `at` defaults to now ISO, `source` to `openvibe-sdk/usage`; null fields stripped; money fields never invented); `usageKey(service, ...parts)` joins an idempotency key with `:`; `validateUsageSample(record)` → `{ok, errors}` (openvibe-contracts, required lazily). `createUsageReporter({ db, service, source, table, billingUrl, tokenClient, audience, requireProject, ... })` owns one service's readings: `key(...parts)`/`sample(fields)`, and `record(t, reading)` queues the reading idempotency-keyed in the outbox inside the caller's transaction, relayed to Billing's `billing.usage.record` with backoff (a retry is never dropped; only Billing refusing the reading itself — any 4xx but 401/403/404/408/425/429, which stay pending — marks a row rejected; a 401 drops the cached token). The reporter bills whatever reading it is given: never record first-party or sandbox traffic (pass `requireProject: true` to refuse a reading without a `prj_…` project). `openvibe-sdk/govern` builds its `onUsage` record with it |
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.48.0",
+ "version": "0.49.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"events: publish, pull and checkpoints by opaque cursor only, app events, webhook verification (v2), outbox and inbox",
"events: publish, pull and checkpoints by opaque cursor only, app events, webhook verification (v2), outbox and inbox",
"realtime SSE (resumes from the opaque cursor, dedupes by event_id)",
"realtime SSE (resumes from the opaque cursor, dedupes by event_id)",
"media files and objects v2",
"media files and objects v2",
- "community, bot, search, chat, ai, jobs, tools, projects, modules, identity, openre, geo, vip, quest and play clients",
+ "community, bot, search, chat, ai, jobs, tools, projects, modules, identity, openre, geo, vip, quest, play and download clients",
"bot (openvibe-sdk/bot): OpenVibe.Bot robots, operators, devices, streaming, commands and audit, the public kits and profiles, and pairing",
"bot (openvibe-sdk/bot): OpenVibe.Bot robots, operators, devices, streaming, commands and audit, the public kits and profiles, and pairing",
"per-actor limiter for services (openvibe-sdk/limits)",
"per-actor limiter for services (openvibe-sdk/limits)",
"the OpenVibe Frame for any app",
"the OpenVibe Frame for any app",
new file mode 100755
new file mode 100755
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * openvibe-download — back a folder up into your OpenVibe.Download drive (openvibe-sdk/download).
+ *
+ * OPENVIBE_DOWNLOAD_KEY=ovdl_… openvibe-download sync ~/Documents --to Backups/laptop [--delete] [--dry-run] [--exclude NAME]…
+ * openvibe-download ls [drive/path]
+ * openvibe-download usage
+ *
+ * Make the key at https://openvibe.download/sync (it is shown once). OPENVIBE_DOWNLOAD_URL points elsewhere (tests).
+ * Exit status: 0 done, 1 something failed or the sync was stopped (the drive's daily cap or quota), 2 bad usage.
+ */
+const { createDownloadClient } = require('../src/download');
+
+const USAGE = `Usage:
+ openvibe-download sync <folder> --to <drive/path> [--delete] [--dry-run] [--exclude NAME]...
+ openvibe-download ls [drive/path]
+ openvibe-download usage
+
+Set OPENVIBE_DOWNLOAD_KEY to a sync key from https://openvibe.download/sync`;
+
+function parse(argv) {
+ const out = { _: [], exclude: [] };
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === '--to') out.to = argv[++i];
+ else if (a === '--exclude') out.exclude.push(argv[++i]);
+ else if (a === '--delete') out.delete = true;
+ else if (a === '--dry-run') out.dryRun = true;
+ else if (a === '--quiet' || a === '-q') out.quiet = true;
+ else if (a === '--help' || a === '-h') out.help = true;
+ else if (a.startsWith('--')) throw new Error(`unknown option ${a}`);
+ else out._.push(a);
+ }
+ return out;
+}
+
+const mb = (n) => `${(n / (1024 * 1024)).toFixed(n < 10 * 1024 * 1024 ? 1 : 0)} MB`;
+
+async function main(argv, env = process.env, out = process.stdout, err = process.stderr) {
+ let args;
+ try { args = parse(argv); } catch (e) { err.write(`${e.message}\n\n${USAGE}\n`); return 2; }
+ const [command, target] = args._;
+ if (args.help || !command) { out.write(`${USAGE}\n`); return command || args.help ? 0 : 2; }
+ const key = env.OPENVIBE_DOWNLOAD_KEY;
+ if (!key) { err.write(`OPENVIBE_DOWNLOAD_KEY is not set.\n\n${USAGE}\n`); return 2; }
+ const dl = createDownloadClient({ key, baseUrl: env.OPENVIBE_DOWNLOAD_URL || undefined });
+ try {
+ if (command === 'usage') {
+ const u = await dl.usage();
+ out.write(`${mb(u.bytes_used)} of ${mb(u.bytes_limit)} stored, ${u.files} files; ${u.uploads_today} uploads today\n`);
+ return 0;
+ }
+ if (command === 'ls') {
+ const folderId = target ? await dl.folders.ensurePath(target) : null;
+ const page = await dl.files.list({ folderId });
+ for (const f of page.folders) out.write(`${f.name}/\n`);
+ for (const f of page.files) out.write(`${f.name}\t${mb(f.size)}\n`);
+ return 0;
+ }
+ if (command === 'sync') {
+ if (!target || !args.to) { err.write(`sync needs a folder and --to <drive/path>.\n\n${USAGE}\n`); return 2; }
+ const report = await dl.sync(target, {
+ to: args.to, delete: Boolean(args.delete), dryRun: Boolean(args.dryRun), exclude: args.exclude,
+ onEvent: (e) => {
+ if (args.quiet && e.type !== 'error') return;
+ if (e.type === 'upload' || e.type === 'replace') out.write(`${args.dryRun ? 'would send' : e.type === 'replace' ? 'update' : 'send'} ${e.path} (${mb(e.size)})\n`);
+ else if (e.type === 'delete') out.write(`${args.dryRun ? 'would delete' : 'delete'} ${e.path}\n`);
+ else if (e.type === 'error') err.write(`failed ${e.path}: ${e.message}\n`);
+ },
+ });
+ out.write(`${args.dryRun ? 'Dry run: ' : ''}${report.uploaded} new, ${report.replaced} updated, ${report.skipped} unchanged, ${report.deleted} deleted, ${report.failed} failed (${mb(report.bytes)})\n`);
+ if (report.stopped) err.write(`Stopped: ${report.stopped.message}\n`);
+ return report.failed || report.stopped ? 1 : 0;
+ }
+ err.write(`unknown command ${command}\n\n${USAGE}\n`);
+ return 2;
+ } catch (e) {
+ err.write(`${e.detail || e.message || e}\n`);
+ return 1;
+ }
+}
+
+if (require.main === module) main(process.argv.slice(2)).then((code) => { process.exitCode = code; });
+
+module.exports = { main, parse };
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.48.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.49.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
/* eslint-disable */
@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.48.0';
+const SDK_VERSION = '0.49.0';
module.exports = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+// Generated by scripts/esm.js from src/download.js. Do not edit.
+import cjs from '../src/download.js';
+
+export const {
+ createDownloadClient, contentTypeOf,
+} = cjs;
+export default cjs;
@@ -8,6 +8,6 @@ export const {
@@ -8,6 +8,6 @@ export const {
registry, identity, modules, events, realtime, media,
registry, identity, modules, events, realtime, media,
community, bot, search, chat, ai, jobs,
community, bot, search, chat, ai, jobs,
tools, projects, resources, vip, quest, play,
tools, projects, resources, vip, quest, play,
- openre, geo, usage, commerce, telemetry,
+ download, openre, geo, usage, commerce, telemetry,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
@@ -27,6 +27,7 @@ module.exports = {
@@ -27,6 +27,7 @@ module.exports = {
vip: require('./src/vip'),
vip: require('./src/vip'),
quest: require('./src/quest'),
quest: require('./src/quest'),
play: require('./src/play'),
play: require('./src/play'),
+ download: require('./src/download'),
openre: require('./src/openre'),
openre: require('./src/openre'),
geo: require('./src/geo'),
geo: require('./src/geo'),
usage: require('./src/usage'),
usage: require('./src/usage'),
@@ -1,13 +1,16 @@
@@ -1,13 +1,16 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.48.0",
+ "version": "0.49.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.48.0",
+ "version": "0.49.0",
"license": "MIT",
"license": "MIT",
+ "bin": {
+ "openvibe-download": "bin/openvibe-download.js"
+ },
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.48.0",
+ "version": "0.49.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -242,6 +242,12 @@
@@ -242,6 +242,12 @@
"import": "./esm/play.mjs",
"import": "./esm/play.mjs",
"default": "./src/play.js"
"default": "./src/play.js"
},
},
+ "./download": {
+ "types": "./types/download.d.ts",
+ "browser": null,
+ "import": "./esm/download.mjs",
+ "default": "./src/download.js"
+ },
"./quest": {
"./quest": {
"types": "./types/quest.d.ts",
"types": "./types/quest.d.ts",
"browser": null,
"browser": null,
@@ -272,7 +278,11 @@
@@ -272,7 +278,11 @@
},
},
"./package.json": "./package.json"
"./package.json": "./package.json"
},
},
+ "bin": {
+ "openvibe-download": "bin/openvibe-download.js"
+ },
"files": [
"files": [
+ "bin/",
"index.js",
"index.js",
"browser.js",
"browser.js",
"src/",
"src/",
@@ -53,6 +53,7 @@ const ENTRIES = {
@@ -53,6 +53,7 @@ const ENTRIES = {
'geo.mjs': '../src/geo.js',
'geo.mjs': '../src/geo.js',
'openre.mjs': '../src/openre.js',
'openre.mjs': '../src/openre.js',
'play.mjs': '../src/play.js',
'play.mjs': '../src/play.js',
+ 'download.mjs': '../src/download.js',
'quest.mjs': '../src/quest.js',
'quest.mjs': '../src/quest.js',
'vip.mjs': '../src/vip.js',
'vip.mjs': '../src/vip.js',
'frame.mjs': '../src/frame.js',
'frame.mjs': '../src/frame.js',
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.48.0';
+const SDK_VERSION = '0.49.0';
module.exports = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,269 @@
@@ -0,0 +1,269 @@
+'use strict';
+/**
+ * openvibe-sdk/download — OpenVibe.Download, a person's drive at openvibe.download, from a script or the
+ * `openvibe-download` command: list it, make folders, upload in parts, delete, and `sync()`, which keeps a folder on
+ * this computer backed up in the drive. Server-side (Node): it reads files from disk.
+ *
+ * It authenticates with a sync key the person makes at https://openvibe.download/sync (`ovdl_…`, shown once; it acts
+ * on their drive and nothing else), or with the person's own Network token.
+ *
+ * const dl = createDownloadClient({ key: process.env.OPENVIBE_DOWNLOAD_KEY });
+ * await dl.files.list({ folderId }) // → { folder_id, folders, files, usage }
+ * const folderId = await dl.folders.ensurePath('Backups/laptop');
+ * await dl.upload({ path: './notes.txt', folderId }); // → the file (or { data, name } for bytes)
+ * const report = await dl.sync('/home/kim/Documents', { to: 'Backups/laptop' });
+ * // report: { uploaded, skipped, replaced, deleted, failed, bytes, errors }
+ *
+ * sync(dir, { to, delete, dryRun, exclude, onEvent }): walks `dir` (symbolic links are not followed), mirrors its
+ * folders under `to`, and uploads a file whose drive copy is missing or differs (size, or sha256 when the drive has
+ * one). A changed file is uploaded again and the older copy deleted. With `delete: true` drive files under `to` that
+ * have no local copy are removed (a folder whose local folder is gone is emptied); without it a backup only adds. `exclude` lists names to skip
+ * (exact, or `*.ext`); `.DS_Store`, `Thumbs.db` and `desktop.ini` are always skipped. A rate limit (429) is waited
+ * out and retried; the drive's daily file cap or storage quota stops the sync with the reason.
+ */
+const fs = require('fs');
+const fsp = require('fs/promises');
+const path = require('path');
+const crypto = require('crypto');
+const { createClient } = require('./core/client');
+const { isOpenVibeError } = require('./core/errors');
+
+const DEFAULT_URL = 'https://openvibe.download';
+const ALWAYS_SKIP = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini']);
+const STOPPING = new Set(['file.daily_limit', 'file.quota', 'sync_key.invalid', 'token.required']);
+const CONTENT_TYPES = {
+ '.txt': 'text/plain', '.md': 'text/plain', '.csv': 'text/csv', '.json': 'application/json', '.pdf': 'application/pdf',
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
+ '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.mp4': 'video/mp4', '.webm': 'video/webm', '.zip': 'application/zip',
+};
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+function contentTypeOf(name) {
+ return CONTENT_TYPES[path.extname(String(name)).toLowerCase()] || 'application/octet-stream';
+}
+
+async function sha256File(file) {
+ const hash = crypto.createHash('sha256');
+ await new Promise((resolve, reject) => {
+ fs.createReadStream(file).on('data', (c) => hash.update(c)).on('end', resolve).on('error', reject);
+ });
+ return hash.digest('hex');
+}
+
+function excluded(name, patterns) {
+ if (ALWAYS_SKIP.has(name)) return true;
+ return patterns.some((p) => (p.startsWith('*.') ? name.toLowerCase().endsWith(p.slice(1).toLowerCase()) : name === p));
+}
+
+function createDownloadClient(options = {}) {
+ const baseUrl = String(options.baseUrl || DEFAULT_URL).replace(/\/+$/, '');
+ const token = options.key || options.token;
+ if (!token) throw new TypeError('createDownloadClient: pass { key } (a sync key from openvibe.download/sync) or { token }');
+ const client = options.client || createClient({ token, baseUrls: { download: baseUrl }, retries: 2 });
+ const wait = options.sleep || sleep;
+
+ /** One call, a rate limit waited out (Retry-After, else a minute) up to `tries` times. */
+ async function call(opts, tries = 6) {
+ for (let attempt = 1; ; attempt++) {
+ try {
+ return await client.request({ service: 'download', baseUrl, ...opts });
+ } catch (err) {
+ if (!isOpenVibeError(err) || err.status !== 429 || attempt >= tries || (err.code && err.code !== 'rate_limited')) throw err;
+ const seconds = Number(err.problem && (err.problem.retry_after_seconds || err.problem.retry_after)) || 60;
+ await wait(Math.min(300, Math.max(1, seconds)) * 1000);
+ }
+ }
+ }
+ const json = async (opts) => (await call(opts)).data;
+
+ const files = {
+ /** One folder's files and subfolders (or the root's), up to 200 a page; `before` pages back. */
+ list: ({ folderId = null, limit = 200, before = null } = {}) => json({
+ path: '/api/v1/files', query: { folder_id: folderId || undefined, limit, before: before || undefined },
+ }),
+ /** Every file in one folder, all pages. */
+ async all({ folderId = null } = {}) {
+ const out = [];
+ let before = null;
+ for (;;) {
+ const page = await files.list({ folderId, before });
+ out.push(...page.files);
+ if (page.files.length < 200) return out;
+ before = page.files[page.files.length - 1].id;
+ }
+ },
+ get: (id) => json({ path: `/api/v1/files/${encodeURIComponent(id)}` }),
+ remove: (id) => call({ method: 'DELETE', path: `/api/v1/files/${encodeURIComponent(id)}`, idempotencyKey: false }),
+ };
+
+ const folders = {
+ list: async () => (await json({ path: '/api/v1/folders' })).folders,
+ create: async (name, parentId = null) => (await json({
+ method: 'POST', path: '/api/v1/folders', json: { name, parent_id: parentId }, idempotencyKey: false,
+ })).folder,
+ /** The id of the folder at `drivePath` ('Backups/laptop'), making the missing ones; null for the root. */
+ async ensurePath(drivePath, { known = null } = {}) {
+ const parts = String(drivePath || '').split('/').map((p) => p.trim()).filter(Boolean);
+ const all = known || await folders.list();
+ let parent = null;
+ for (const name of parts) {
+ let found = all.find((f) => (f.parent_id || null) === parent && f.name.toLowerCase() === name.toLowerCase());
+ if (!found) {
+ found = await folders.create(name, parent);
+ all.push(found);
+ }
+ parent = found.id;
+ }
+ return parent;
+ },
+ };
+
+ /** Upload one file: { path } from disk, or { data, name }; → the drive's file. */
+ async function upload({ path: file = null, data = null, name = null, folderId = null, contentType = null } = {}) {
+ const fileName = name || (file ? path.basename(file) : null);
+ if (!fileName) throw new TypeError('upload: pass { path } or { data, name }');
+ const size = file ? (await fsp.stat(file)).size : Buffer.byteLength(data);
+ if (size < 1) throw new TypeError(`upload: ${fileName} is empty (the drive keeps files of at least one byte)`);
+ const started = await json({
+ method: 'POST', path: '/api/v1/files', idempotencyKey: false,
+ json: { name: fileName, size, content_type: contentType || contentTypeOf(fileName), folder_id: folderId },
+ });
+ const up = started.upload;
+ const handle = file ? await fsp.open(file, 'r') : null;
+ try {
+ for (let n = 1; n <= up.parts_expected; n++) {
+ const start = (n - 1) * up.part_size;
+ const length = Math.min(up.part_size, size - start);
+ let body;
+ if (handle) {
+ body = Buffer.alloc(length);
+ await handle.read(body, 0, length, start);
+ } else {
+ body = Buffer.from(data).subarray(start, start + length);
+ }
+ await call({ method: 'PUT', path: `/api/v1/uploads/${up.id}/parts/${n}`, body, headers: { 'content-type': 'application/octet-stream' } });
+ }
+ } catch (err) {
+ await call({ method: 'DELETE', path: `/api/v1/uploads/${up.id}`, idempotencyKey: false }).catch(() => {});
+ throw err;
+ } finally {
+ if (handle) await handle.close();
+ }
+ return (await json({ method: 'POST', path: `/api/v1/uploads/${up.id}/complete`, json: {}, idempotencyKey: false })).file;
+ }
+
+ const usage = async () => (await json({ path: '/api/v1/usage' })).usage;
+
+ /** Keep `dir` backed up under the drive folder `to` (see the module comment). */
+ async function sync(dir, { to = '', delete: prune = false, dryRun = false, exclude = [], onEvent = () => {} } = {}) {
+ const root = path.resolve(dir);
+ const stat = await fsp.stat(root);
+ if (!stat.isDirectory()) throw new TypeError(`sync: ${root} is not a folder`);
+ const report = { uploaded: 0, replaced: 0, skipped: 0, deleted: 0, failed: 0, bytes: 0, errors: [] };
+ const allFolders = await folders.list();
+ const emit = (e) => { try { onEvent(e); } catch { /* the caller's printer must not stop the sync */ } };
+ let stopped = null;
+
+ const childFolder = (parentId, name) => allFolders.find((f) => (f.parent_id || null) === parentId && f.name.toLowerCase() === name.toLowerCase());
+
+ async function walk(localDir, folderId, rel) {
+ if (stopped) return;
+ const entries = (await fsp.readdir(localDir, { withFileTypes: true }))
+ .filter((e) => !excluded(e.name, exclude))
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
+ const remote = folderId === undefined ? [] : await files.all({ folderId });
+ const byName = new Map();
+ for (const f of remote) {
+ const list = byName.get(f.name) || [];
+ list.push(f);
+ byName.set(f.name, list);
+ }
+ const seenFiles = new Set();
+ const seenDirs = new Set();
+ for (const e of entries) {
+ if (stopped) return;
+ const local = path.join(localDir, e.name);
+ const relPath = rel ? `${rel}/${e.name}` : e.name;
+ if (e.isSymbolicLink()) { report.skipped++; emit({ type: 'skip', path: relPath, reason: 'symbolic link' }); continue; }
+ if (e.isDirectory()) {
+ seenDirs.add(e.name.toLowerCase());
+ let sub = folderId === undefined ? undefined : childFolder(folderId, e.name);
+ if (!sub && !dryRun) {
+ sub = await folders.create(e.name, folderId);
+ allFolders.push(sub);
+ emit({ type: 'folder', path: relPath });
+ }
+ await walk(local, sub ? sub.id : undefined, relPath);
+ continue;
+ }
+ if (!e.isFile()) continue;
+ seenFiles.add(e.name);
+ const st = await fsp.stat(local);
+ if (st.size < 1) { report.skipped++; emit({ type: 'skip', path: relPath, reason: 'empty' }); continue; }
+ const copies = (byName.get(e.name) || []).sort((a, b) => (a.created_at < b.created_at ? 1 : -1));
+ const current = copies[0] || null;
+ let same = false;
+ if (current && Number(current.size) === st.size) {
+ same = current.sha256 ? current.sha256 === await sha256File(local) : true;
+ }
+ if (same) { report.skipped++; emit({ type: 'same', path: relPath }); continue; }
+ emit({ type: current ? 'replace' : 'upload', path: relPath, size: st.size, dryRun });
+ if (dryRun) { report[current ? 'replaced' : 'uploaded']++; report.bytes += st.size; continue; }
+ try {
+ await upload({ path: local, folderId });
+ report[current ? 'replaced' : 'uploaded']++;
+ report.bytes += st.size;
+ for (const old of copies) await files.remove(old.id);
+ } catch (err) {
+ report.failed++;
+ const code = isOpenVibeError(err) ? err.code : null;
+ report.errors.push({ path: relPath, code, message: String(err.detail || err.message || err) });
+ emit({ type: 'error', path: relPath, code, message: String(err.detail || err.message || err) });
+ if (code && STOPPING.has(code)) stopped = { code, message: String(err.detail || err.message) };
+ }
+ }
+ if (!prune || folderId === undefined) return;
+ for (const [name, list] of byName) {
+ if (seenFiles.has(name)) continue;
+ for (const f of list) {
+ emit({ type: 'delete', path: rel ? `${rel}/${name}` : name, dryRun });
+ if (!dryRun) await files.remove(f.id);
+ report.deleted++;
+ }
+ }
+ // A drive folder whose local folder is gone: its files go (the folder itself stays, empty).
+ for (const sub of allFolders.filter((f) => (f.parent_id || null) === folderId && !seenDirs.has(f.name.toLowerCase()))) {
+ await empty(sub.id, rel ? `${rel}/${sub.name}` : sub.name);
+ }
+ }
+
+ async function empty(folderId, rel) {
+ for (const f of await files.all({ folderId })) {
+ emit({ type: 'delete', path: `${rel}/${f.name}`, dryRun });
+ if (!dryRun) await files.remove(f.id);
+ report.deleted++;
+ }
+ for (const sub of allFolders.filter((f) => f.parent_id === folderId)) await empty(sub.id, `${rel}/${sub.name}`);
+ }
+
+ const top = dryRun
+ ? (await (async () => {
+ let parent = null;
+ for (const name of String(to).split('/').map((p) => p.trim()).filter(Boolean)) {
+ const f = childFolder(parent, name);
+ if (!f) return undefined;
+ parent = f.id;
+ }
+ return parent;
+ })())
+ : await folders.ensurePath(to, { known: allFolders });
+ await walk(root, top, '');
+ if (stopped) report.stopped = stopped;
+ return report;
+ }
+
+ return { files, folders, upload, usage, sync };
+}
+
+module.exports = { createDownloadClient, contentTypeOf };
@@ -76,7 +76,7 @@ run([
@@ -76,7 +76,7 @@ run([
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
}
}
assert.deepEqual(problems, []);
assert.deepEqual(problems, []);
- assert.deepEqual(blocked.sort(), ['./account-data', './cache', './commerce', './cost', './db', './delivery', './events', './govern', './identity', './limits', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './runtime', './service', './sso', './storage', './telemetry', './testing', './usage', './valkey', './vip']);
+ assert.deepEqual(blocked.sort(), ['./account-data', './cache', './commerce', './cost', './db', './delivery', './download', './events', './govern', './identity', './limits', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './runtime', './service', './sso', './storage', './telemetry', './testing', './usage', './valkey', './vip']);
const rel = [...scanned].map((f) => path.relative(ROOT, f));
const rel = [...scanned].map((f) => path.relative(ROOT, f));
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
assert.ok(rel.includes(must), `${must} is scanned`);
assert.ok(rel.includes(must), `${must} is scanned`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,170 @@
@@ -0,0 +1,170 @@
+'use strict';
+// openvibe-sdk/download: OpenVibe.Download's drive API from a script, and sync() backups, against a small stand-in
+// drive (the routes, the parts and the sha256 the real one keeps). Checks: folders mirrored, only new or changed files
+// sent, a changed file replaced, --delete, a dry run that writes nothing, 429 waited out, the daily cap stopping the
+// sync, and the openvibe-download command's exit codes.
+const assert = require('assert');
+const crypto = require('crypto');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const { createDownloadClient } = require('../src/download');
+const { main } = require('../bin/openvibe-download');
+
+const KEY = `ovdl_${'k'.repeat(43)}`;
+
+function startDrive({ dailyCap = 1000 } = {}) {
+ const state = { folders: [], files: [], uploads: new Map(), seq: 0, uploadsToday: 0, throttleNext: false, requests: [] };
+ const id = (p) => `${p}_${String(++state.seq).padStart(26, '0')}`;
+ const send = (res, status, body) => {
+ res.writeHead(status, { 'content-type': status >= 400 ? 'application/problem+json' : 'application/json' });
+ res.end(body === undefined ? '' : JSON.stringify(body));
+ };
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const url = new URL(req.url, 'http://drive.test');
+ const body = Buffer.concat(chunks);
+ state.requests.push(`${req.method} ${url.pathname}`);
+ if (req.headers.authorization !== `Bearer ${KEY}`) return send(res, 401, { code: 'sync_key.invalid', detail: 'bad key' });
+ const json = () => (body.length ? JSON.parse(body.toString('utf8')) : {});
+ const p = url.pathname;
+ if (req.method === 'GET' && p === '/api/v1/folders') return send(res, 200, { folders: state.folders });
+ if (req.method === 'POST' && p === '/api/v1/folders') {
+ const b = json();
+ const f = { id: id('fld'), name: b.name, parent_id: b.parent_id || null, created_at: new Date().toISOString() };
+ state.folders.push(f);
+ return send(res, 201, { folder: f });
+ }
+ if (req.method === 'GET' && p === '/api/v1/files') {
+ const folderId = url.searchParams.get('folder_id') || null;
+ return send(res, 200, {
+ folder_id: folderId,
+ folders: state.folders.filter((f) => f.parent_id === folderId),
+ files: state.files.filter((f) => f.folder_id === folderId),
+ usage: { bytes_used: 0, bytes_limit: 1, files: state.files.length, uploads_today: state.uploadsToday },
+ });
+ }
+ if (req.method === 'POST' && p === '/api/v1/files') {
+ if (state.throttleNext) {
+ state.throttleNext = false;
+ return send(res, 429, { code: 'rate_limited', detail: 'slow down', retry_after_seconds: 1 });
+ }
+ if (state.uploadsToday >= dailyCap) return send(res, 429, { code: 'file.daily_limit', detail: 'You have uploaded enough files today.' });
+ state.uploadsToday++;
+ const b = json();
+ const up = { id: id('upl'), name: b.name, size: b.size, folder_id: b.folder_id || null, part_size: 4, parts_expected: Math.ceil(b.size / 4), parts: [] };
+ state.uploads.set(up.id, up);
+ return send(res, 201, { upload: { id: up.id, part_size: up.part_size, parts_expected: up.parts_expected, missing: [] } });
+ }
+ let m = /^\/api\/v1\/uploads\/([^/]+)\/parts\/(\d+)$/.exec(p);
+ if (req.method === 'PUT' && m) {
+ state.uploads.get(m[1]).parts[Number(m[2]) - 1] = body;
+ return send(res, 200, { part: Number(m[2]), size: body.length });
+ }
+ m = /^\/api\/v1\/uploads\/([^/]+)\/complete$/.exec(p);
+ if (req.method === 'POST' && m) {
+ const up = state.uploads.get(m[1]);
+ const data = Buffer.concat(up.parts);
+ const file = { id: id('fil'), name: up.name, size: data.length, sha256: crypto.createHash('sha256').update(data).digest('hex'), folder_id: up.folder_id, created_at: new Date(Date.now() + state.seq).toISOString(), data };
+ state.files.push(file);
+ const { data: _d, ...wire } = file;
+ return send(res, 201, { file: wire });
+ }
+ m = /^\/api\/v1\/files\/([^/]+)$/.exec(p);
+ if (req.method === 'DELETE' && m) {
+ state.files = state.files.filter((f) => f.id !== m[1]);
+ return send(res, 204);
+ }
+ if (req.method === 'GET' && p === '/api/v1/usage') return send(res, 200, { usage: { bytes_used: 10, bytes_limit: 100, files: state.files.length, uploads_today: state.uploadsToday } });
+ return send(res, 404, { code: 'route.not_found' });
+ });
+ });
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ state, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) })));
+}
+
+function tree(files) {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ovdl-'));
+ for (const [rel, text] of Object.entries(files)) {
+ fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
+ fs.writeFileSync(path.join(dir, rel), text);
+ }
+ return dir;
+}
+
+(async () => {
+ const drive = await startDrive();
+ const noWait = async () => {};
+ const dl = createDownloadClient({ key: KEY, baseUrl: drive.url, sleep: noWait });
+ const local = tree({ 'notes.txt': 'hello world', 'photos/cat.jpg': 'meow meow', 'photos/.DS_Store': 'x', 'empty.txt': '' });
+ try {
+ // A dry run writes nothing.
+ const dry = await dl.sync(local, { to: 'Backups/laptop', dryRun: true });
+ assert.deepStrictEqual([dry.uploaded, drive.state.files.length, drive.state.folders.length], [2, 0, 0], 'a dry run sends and makes nothing');
+
+ // The first sync mirrors the folders and sends both files (the empty file and .DS_Store are skipped), and a
+ // rate limit on the way is waited out.
+ drive.state.throttleNext = true;
+ const first = await dl.sync(local, { to: 'Backups/laptop' });
+ assert.strictEqual(first.uploaded, 2, JSON.stringify(first));
+ assert.strictEqual(first.failed, 0);
+ const names = drive.state.folders.map((f) => f.name).sort();
+ assert.deepStrictEqual(names, ['Backups', 'laptop', 'photos']);
+ const cat = drive.state.files.find((f) => f.name === 'cat.jpg');
+ assert.strictEqual(cat.data.toString(), 'meow meow', 'parts arrive in order');
+ assert.ok(!drive.state.files.some((f) => f.name === '.DS_Store' || f.name === 'empty.txt'));
+
+ // Unchanged: nothing sent. Changed: sent again and the older copy deleted.
+ const again = await dl.sync(local, { to: 'Backups/laptop' });
+ assert.deepStrictEqual([again.uploaded, again.replaced, again.skipped >= 2], [0, 0, true]);
+ fs.writeFileSync(path.join(local, 'notes.txt'), 'hello there, world');
+ const changed = await dl.sync(local, { to: 'Backups/laptop' });
+ assert.strictEqual(changed.replaced, 1);
+ const notes = drive.state.files.filter((f) => f.name === 'notes.txt');
+ assert.strictEqual(notes.length, 1, 'the older copy is gone');
+ assert.strictEqual(notes[0].data.toString(), 'hello there, world');
+
+ // --delete removes drive files whose local copy is gone, including inside a vanished folder.
+ fs.rmSync(path.join(local, 'photos'), { recursive: true });
+ const kept = await dl.sync(local, { to: 'Backups/laptop' });
+ assert.strictEqual(kept.deleted, 0, 'without --delete a backup only adds');
+ const pruned = await dl.sync(local, { to: 'Backups/laptop', delete: true });
+ assert.strictEqual(pruned.deleted, 1);
+ assert.ok(!drive.state.files.some((f) => f.name === 'cat.jpg'));
+
+ // The command: usage, ls, a missing key, a bad folder.
+ const out = [];
+ const err = [];
+ const w = (arr) => ({ write: (s) => { arr.push(s); return true; } });
+ const env = { OPENVIBE_DOWNLOAD_KEY: KEY, OPENVIBE_DOWNLOAD_URL: drive.url };
+ assert.strictEqual(await main(['usage'], env, w(out), w(err)), 0);
+ assert.match(out.join(''), /stored/);
+ assert.strictEqual(await main(['ls', 'Backups/laptop'], env, w(out), w(err)), 0);
+ assert.match(out.join(''), /notes\.txt/);
+ assert.strictEqual(await main(['sync', local], env, w(out), w(err)), 2, 'sync needs --to');
+ assert.strictEqual(await main(['usage'], {}, w(out), w(err)), 2, 'no key');
+ assert.strictEqual(await main(['sync', local, '--to', 'B', '--bogus'], env, w(out), w(err)), 2);
+ } finally {
+ await drive.close();
+ }
+
+ // The drive's daily cap stops the sync with its reason, and the command exits 1.
+ const capped = await startDrive({ dailyCap: 1 });
+ try {
+ const dir = tree({ 'a.txt': 'aaaa', 'b.txt': 'bbbb', 'c.txt': 'cccc' });
+ const report = await createDownloadClient({ key: KEY, baseUrl: capped.url, sleep: noWait }).sync(dir, { to: 'B' });
+ assert.strictEqual(report.uploaded, 1);
+ assert.strictEqual(report.stopped && report.stopped.code, 'file.daily_limit');
+ assert.strictEqual(report.failed, 1, 'it stops at the first refusal');
+ const out = [];
+ const code = await main(['sync', dir, '--to', 'B', '-q'], { OPENVIBE_DOWNLOAD_KEY: KEY, OPENVIBE_DOWNLOAD_URL: capped.url }, { write: (s) => out.push(s) }, { write: (s) => out.push(s) });
+ assert.strictEqual(code, 1);
+ assert.match(out.join(''), /Stopped: You have uploaded enough files today/);
+ } finally {
+ await capped.close();
+ }
+ assert.throws(() => createDownloadClient({}), /sync key/);
+ console.log('download: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -66,7 +66,7 @@ run([
@@ -66,7 +66,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
- assert.equal(subpaths.length, 46);
+ assert.equal(subpaths.length, 47);
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -0,0 +1,28 @@
+/** openvibe-sdk/download: OpenVibe.Download, a person's drive — list, folders, uploads in parts, and sync() backups. */
+export interface DriveFile { id: string; name: string; size: number; content_type: string; sha256: string | null; folder_id: string | null; created_at: string }
+export interface DriveFolder { id: string; name: string; parent_id: string | null; created_at: string }
+export interface DriveUsage { bytes_used: number; bytes_limit: number; max_file_bytes: number; files: number; uploads_today: number; uploads_per_day: number }
+export interface SyncEvent { type: 'upload' | 'replace' | 'same' | 'skip' | 'folder' | 'delete' | 'error'; path: string; size?: number; reason?: string; code?: string | null; message?: string; dryRun?: boolean }
+export interface SyncReport {
+ uploaded: number; replaced: number; skipped: number; deleted: number; failed: number; bytes: number;
+ errors: { path: string; code: string | null; message: string }[];
+ stopped?: { code: string; message: string };
+}
+export interface DownloadClient {
+ files: {
+ list(opts?: { folderId?: string | null; limit?: number; before?: string | null }): Promise<{ folder_id: string | null; folders: DriveFolder[]; files: DriveFile[]; usage: DriveUsage }>;
+ all(opts?: { folderId?: string | null }): Promise<DriveFile[]>;
+ get(id: string): Promise<{ file: DriveFile }>;
+ remove(id: string): Promise<unknown>;
+ };
+ folders: {
+ list(): Promise<DriveFolder[]>;
+ create(name: string, parentId?: string | null): Promise<DriveFolder>;
+ ensurePath(drivePath: string, opts?: { known?: DriveFolder[] }): Promise<string | null>;
+ };
+ upload(input: { path?: string; data?: Uint8Array | string; name?: string; folderId?: string | null; contentType?: string }): Promise<DriveFile>;
+ usage(): Promise<DriveUsage>;
+ sync(dir: string, opts?: { to?: string; delete?: boolean; dryRun?: boolean; exclude?: string[]; onEvent?: (e: SyncEvent) => void }): Promise<SyncReport>;
+}
+export function createDownloadClient(options: { key?: string; token?: string; baseUrl?: string; client?: unknown; sleep?: (ms: number) => Promise<void> }): DownloadClient;
+export function contentTypeOf(name: string): string;
@@ -18,6 +18,7 @@ export * as resources from './resources';
@@ -18,6 +18,7 @@ export * as resources from './resources';
export * as vip from './vip';
export * as vip from './vip';
export * as quest from './quest';
export * as quest from './quest';
export * as play from './play';
export * as play from './play';
+export * as download from './download';
export * as openre from './openre';
export * as openre from './openre';
export * as geo from './geo';
export * as geo from './geo';
export * as usage from './usage';
export * as usage from './usage';