openvibers/openvibe.sdk Commits
SDK 0.36.0: openvibe-sdk/account-data (ADR-033 export and deletion from a table map) (#24)
Changes
@@@ -3,6 +3,18 @@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.36.0 (2026-10-08)++++- **`openvibe-sdk/account-data` (new, node): a service's share of account export and deletion (ADR-033).** Six services++ implemented it by hand; every newer one (Actor, Food, Help, Work, Quest, Rent, Watch…) had none. A service now++ declares the tables that hold a person's rows (`{ table, subject, value?, file?, columns?, erase? }`, with `erase`++ `'delete'`, `{ anonymize: {…} }` or `{ keep: '<why>' }`) and gets `apply(event, { send })` for++ network.account.export_requested / network.account.deleted, a signed `consumer()` handler for POST /internal/events,++ `exportPart()`, `erase()` (one transaction, merged-account aliases included), `ensureSchema()` /++ `ACCOUNT_DATA_SCHEMA` (account_data_events: idempotent per export/deletion id, so a redelivery never erases twice and a++ failed confirmation is sent again) and `createNetworkSender()` (the service's own client-credentials token for++ Network's internal routes). Identifiers in the table map are checked when it is made; values are always parameters.++## 0.35.2 (2026-10-08)- **The mock developer platform grants `services.release.manage|read` (`openvibe-sdk/testing`, patch):** openvibe-contracts@@@ -2,7 +2,7 @@> Supported browser and server clients for the OpenVibe platform.--**Status:** alpha, v0.35.2 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.++**Status:** alpha, v0.36.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.@@@ -174,6 +174,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client| `openvibe-sdk/bot` | both | `createBotClient(client, {actingSubject?})`: OpenVibe.Bot robots. `robots.list/get/create/update/delete/pairingCode`, `robots.operators.list/add/remove`, `robots.devices`, `robots.streaming.get/set`, `robots.audit`/`iterateAudit`, `robots.estop`/`clearEstop`, `robots.command` (`bot.command@1`), `devices.rotate/revoke`, public `profiles.list/get` and `kits.list/get`, `pair` (the agent's one-time code, never retried), `as(subject)`; a service acts for a person with `X-OV-Subject` (capabilities `bot.robot.read`/`manage`/`control`, `bot.device.connect`) || `openvibe-sdk/search` | both | `createSearchClient(client, {actingSubject?})`: `query(text, {owner, type, lang, filter, facets, limit, cursor})`, `iterate(text, {…, max})`, `suggest(text, {owner, type, limit})`, `document(owner, type, id)` (null when missing or not visible) || `openvibe-sdk/limits` | node | `createActorLimiter({limits: {minute, hour, day}, actor?, maxActors?, onLimited?})` → `limits(name, {minute?, hour?, day?})` Express middleware: per-actor fixed windows at a capability boundary (service principal, else the signed-in person, else `ip:`), 429 problem+json `rate_limited` with Retry-After before the handler; `defaultActor(req)`, `.stats()` |++| `openvibe-sdk/account-data` | node | `createAccountData({db, service, tables: [{table, subject, value?, file?, columns?, erase?}], extraExport?, extraErase?})` → `.apply(event, {send})`, `.consumer({secrets, send, onEvent?})` (a POST /internal/events handler), `.exportPart(subject)`, `.erase(subjects)`, `.ensureSchema()`; `createNetworkSender({networkInternalUrl, clientId, clientSecret})`: a service's share of account export and deletion (ADR-033), idempotent per export/deletion id || `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `exportMine()` || `openvibe-sdk/ai` | both | `createAiClient(client)`: `runs.create(workflow, input, {wait, idempotencyKey, …})`, `runs.get/list/cancel/retry/citations/addCitations/waitFor`, `chat/generate/summarize/classify/extract/enrich/embed` || `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `retry`, `reference`, `unreference`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()`. Default origin: the Tools gateway's jobs facade |@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.35.2",++ "version": "0.36.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",@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.35.2, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.36.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.35.2';++const SDK_VERSION = '0.36.0';module.exports = {createClient,nnew file mode 100644@@@ -0,0 +1,7 @@++// Generated by scripts/esm.js from src/account-data.js. Do not edit.++import cjs from '../src/account-data.js';++++export const {++ createAccountData, createNetworkSender, ACCOUNT_DATA_SCHEMA, TOPICS,++} = cjs;++export default cjs;@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.35.2",++ "version": "0.36.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.35.2",++ "version": "0.36.0","license": "MIT","devDependencies": {"@electric-sql/pglite": "^0.5.8",@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.35.2",++ "version": "0.36.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",@@@ -87,6 +87,12 @@"import": "./esm/limits.mjs","default": "./src/limits.js"},++ "./account-data": {++ "types": "./types/account-data.d.ts",++ "browser": null,++ "import": "./esm/account-data.mjs",++ "default": "./src/account-data.js"++ },"./govern": {"types": "./types/govern.d.ts","browser": null,@@@ -27,6 +27,7 @@ const ENTRIES = {'bot.mjs': '../src/bot.js','search.mjs': '../src/search.js','limits.mjs': '../src/limits.js',++ 'account-data.mjs': '../src/account-data.js','govern.mjs': '../src/govern.js','placement.mjs': '../src/placement.js','usage.mjs': '../src/usage.js',nnew file mode 100644@@@ -0,0 +1,278 @@++'use strict';++/**++ * openvibe-sdk/account-data — a service's share of account export and deletion (ADR-033).++ *++ * Network asks every service holding network.account.export.contribute and network.account.deletion.confirm for its++ * part. network.account.export_requested wants the person's rows, pushed to POST /internal/account-exports/:id/parts++ * (network.account-export-part@1). network.account.deleted wants them gone, or made authorless where other people's++ * rows hang under them, and confirmed with counts at POST /internal/account-deletions/:id/confirmations++ * (network.account-deletion-confirmation@1). Network waits for every holder of those grants, so a service adopts this++ * module and its Events subscription first, and asks for the grants last.++ *++ * const { createAccountData, createNetworkSender } = require('openvibe-sdk/account-data');++ * const accountData = createAccountData({ db, service: 'food', tables: [++ * { table: 'food_plans', subject: 'owner', value: (usr) => `user:${usr}`, file: 'plans.json' },++ * { table: 'food_pantry', subject: 'owner', value: (usr) => `user:${usr}`, file: 'pantry.json' },++ * ] });++ * await accountData.ensureSchema(); // or ACCOUNT_DATA_SCHEMA in a migration++ * const send = createNetworkSender({ networkInternalUrl, clientId, clientSecret });++ * app.use('/internal/events', accountData.consumer({ secrets: [process.env.FOOD_EVENTS_SECRET], send }));++ * // or, inside a consumer the service already has: const outcome = await accountData.apply(event, { send });++ *++ * A table entry:++ * table the table (a plain lower-case identifier)++ * subject the column naming the person++ * value usr_… → what that column stores (default: the id itself); may return an array of forms++ * file the export file name (default '<table>.json'); null leaves the table out of the export++ * columns the exported columns (default: all). List them when a table holds anything that is not the person's to++ * read (a token, a secret, someone else's private note).++ * order the export order column (default: created_at when the table has it, else the first column)++ * erase 'delete' (default)++ * | { anonymize: { <column>: <value>, … } } the row stays, the subject column becomes NULL and the listed++ * columns take the values (e.g. { body: '[deleted]' }): for rows other people's rows hang under++ * | { keep: '<why>' } nothing changes; counted as retained (a ledger the law makes us keep)++ * kind the name the counts use (default: the table)++ *++ * `extraExport(db, subject)` → [{ name, content }] and `extraErase(t, subjects, counts)` cover what a table map++ * cannot say (recounting cached totals, files in object storage); extraErase runs inside the erase transaction.++ *++ * apply() answers 'exported' | 'erased' | 'confirmed' | 'closed' | 'unchanged' | 'ignored:<why>', and throws when++ * Network refuses for a reason worth retrying, so Events redelivers. It is idempotent per export and deletion id++ * (account_data_events): a redelivered deletion never erases twice, and a confirmation that failed is sent again.++ */++const { parseDelivery } = require('./events');++const { createServiceTokenClient } = require('./auth/tokens');++++const TOPICS = Object.freeze(['network.account.export_requested', 'network.account.deleted']);++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const EXPORT_RE = /^exp_[0-9A-HJKMNP-TV-Z]{26}$/;++const DELETION_RE = /^del_[0-9A-HJKMNP-TV-Z]{26}$/;++const IDENT = /^[a-z_][a-z0-9_]{0,62}$/;++const FILE_RE = /^[a-z0-9][a-z0-9_.-]{0,62}\.json$/;++const MAX_FILES = 64;++++const ACCOUNT_DATA_SCHEMA = `CREATE TABLE IF NOT EXISTS account_data_events (++ id TEXT PRIMARY KEY,++ kind TEXT NOT NULL,++ subject TEXT NOT NULL,++ outcome JSONB,++ applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),++ sent_at TIMESTAMPTZ++)`;++++function ident(name, what) {++ if (typeof name !== 'string' || !IDENT.test(name)) throw new TypeError(`openvibe-sdk/account-data: ${what} "${name}" is not a plain identifier`);++ return name;++}++++function normaliseTables(tables) {++ if (!Array.isArray(tables)) throw new TypeError('openvibe-sdk/account-data: tables must be an array');++ const files = new Set();++ return tables.map((t) => {++ const table = ident(t.table, 'table');++ const subject = ident(t.subject, `${table}: subject column`);++ const file = t.file === null ? null : (t.file || `${table}.json`);++ if (file !== null) {++ if (!FILE_RE.test(file)) throw new TypeError(`openvibe-sdk/account-data: ${table}: file "${file}" must be a flat name ending in .json`);++ if (files.has(file)) throw new TypeError(`openvibe-sdk/account-data: two tables export to ${file}`);++ files.add(file);++ }++ const columns = t.columns ? t.columns.map((c) => ident(c, `${table}: column`)) : null;++ const order = t.order ? ident(t.order, `${table}: order column`) : null;++ let erase = t.erase || 'delete';++ if (erase !== 'delete') {++ if (erase.anonymize) {++ for (const c of Object.keys(erase.anonymize)) ident(c, `${table}: anonymize column`);++ } else if (!(typeof erase.keep === 'string' && erase.keep)) {++ throw new TypeError(`openvibe-sdk/account-data: ${table}: erase is 'delete', { anonymize: {…} } or { keep: '<why>' }`);++ }++ }++ const value = typeof t.value === 'function' ? t.value : (s) => s;++ return { table, subject, file, columns, order, erase, value, kind: t.kind || table };++ });++}++++function createAccountData({ db, service, tables, extraExport = null, extraErase = null, note = null, rowLimit = 5000, log = console } = {}) {++ if (!db || typeof db.many !== 'function' || typeof db.tx !== 'function') throw new TypeError('openvibe-sdk/account-data: db must be an openvibe-sdk/db handle');++ if (typeof service !== 'string' || !service) throw new TypeError('openvibe-sdk/account-data: service is required');++ const entries = normaliseTables(tables || []);++ const columnCache = new Map();++++ /** The table's columns, or null when the table does not exist (yet): a table a migration has not made is skipped. */++ async function columnsOf(q, table) {++ if (columnCache.has(table)) return columnCache.get(table);++ const rows = await q.many('SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1', [table]);++ const cols = rows.length ? rows.map((r) => r.column_name) : null;++ if (cols) columnCache.set(table, cols);++ return cols;++ }++++ const formsOf = (entry, subjects) => [...new Set(subjects.flatMap((s) => [].concat(entry.value(s))).filter((v) => v != null).map(String))];++++ async function ensureSchema() { await db.exec(ACCOUNT_DATA_SCHEMA); }++++ /** The person's part: one file per table that has rows for them, newest first, cut at rowLimit. */++ async function exportPart(subject) {++ const files = [];++ const truncated = [];++ for (const e of entries) {++ if (e.file === null) continue;++ const cols = await columnsOf(db, e.table);++ if (!cols || !cols.includes(e.subject)) continue;++ const order = e.order || (cols.includes('created_at') ? 'created_at' : cols[0]);++ const list = e.columns ? e.columns.filter((c) => cols.includes(c)) : null;++ const select = list && list.length ? list.map((c) => `"${c}"`).join(', ') : '*';++ const rows = await db.many(`SELECT ${select} FROM "${e.table}" WHERE "${e.subject}" = ANY($1::text[]) ORDER BY "${order}" DESC LIMIT ${Number(rowLimit) + 1}`, [formsOf(e, [subject])]);++ if (!rows.length) continue;++ if (rows.length > rowLimit) truncated.push(e.file);++ files.push({ name: e.file, content: rows.slice(0, rowLimit) });++ }++ if (extraExport) for (const f of (await extraExport(db, subject)) || []) files.push(f);++ if (files.length > MAX_FILES) throw new Error(`openvibe-sdk/account-data: ${files.length} export files; at most ${MAX_FILES}`);++ const part = { subject, files, truncated };++ if (note) part.note = String(note).slice(0, 500);++ return part;++ }++++ /** Erase everything the subjects (the account and the accounts merged into it) left here, in one transaction. */++ async function erase(subjects) {++ const erased = {};++ const retained = {};++ const add = (o, k, n) => { if (n) o[k] = (o[k] || 0) + n; };++ await db.tx(async (t) => {++ for (const e of entries) {++ const cols = await columnsOf(t, e.table);++ if (!cols || !cols.includes(e.subject)) continue;++ const forms = formsOf(e, subjects);++ if (e.erase === 'delete') {++ add(erased, e.kind, await t.exec(`DELETE FROM "${e.table}" WHERE "${e.subject}" = ANY($1::text[])`, [forms]));++ } else if (e.erase.anonymize) {++ const sets = [`"${e.subject}" = NULL`];++ const values = [forms];++ for (const [c, v] of Object.entries(e.erase.anonymize)) {++ if (!cols.includes(c)) continue;++ values.push(v);++ sets.push(`"${c}" = $${values.length}`);++ }++ add(retained, 'tombstones', await t.exec(`UPDATE "${e.table}" SET ${sets.join(', ')} WHERE "${e.subject}" = ANY($1::text[])`, values));++ } else {++ add(retained, e.kind, Number(await t.value(`SELECT COUNT(*)::int FROM "${e.table}" WHERE "${e.subject}" = ANY($1::text[])`, [forms])) || 0);++ }++ }++ if (extraErase) await extraErase(t, subjects, { erased, retained, add });++ });++ return { erased, retained };++ }++++ /** One envelope → an outcome (see the header); throws to be redelivered. */++ async function apply(ev, { send } = {}) {++ if (!ev || !TOPICS.includes(ev.event_type)) return 'ignored:type';++ if (ev.source !== 'network') return 'ignored:source';++ if (typeof send !== 'function') throw new TypeError('openvibe-sdk/account-data: apply needs { send } (createNetworkSender)');++ const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};++ if (ev.event_type === 'network.account.export_requested') {++ if (!EXPORT_RE.test(String(p.export_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';++ const seen = await db.maybe('SELECT sent_at FROM account_data_events WHERE id = $1', [p.export_id]);++ if (seen && seen.sent_at) return 'unchanged';++ const part = await exportPart(p.subject);++ const res = await send(`/internal/account-exports/${p.export_id}/parts`, part);++ // 404/409: the export was built at its deadline or no longer exists; a late part has nowhere to go.++ const outcome = res.ok ? 'exported' : (res.status === 409 || res.status === 404 ? 'closed' : null);++ if (!outcome) throw new Error(`openvibe-sdk/account-data: export part refused (${res.status})`);++ await db.exec(`INSERT INTO account_data_events (id, kind, subject, outcome, sent_at) VALUES ($1, 'export', $2, $3, now())++ ON CONFLICT (id) DO UPDATE SET outcome = excluded.outcome, sent_at = excluded.sent_at`,++ [p.export_id, p.subject, JSON.stringify({ result: outcome, files: part.files.length })]);++ return outcome;++ }++ if (!DELETION_RE.test(String(p.deletion_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';++ let rec = await db.maybe('SELECT * FROM account_data_events WHERE id = $1', [p.deletion_id]);++ let result = 'confirmed';++ if (!rec) {++ const subjects = [p.subject, ...(Array.isArray(p.aliases) ? p.aliases.filter((s) => SUBJECT_RE.test(String(s))) : [])];++ const counts = await erase(subjects);++ await db.exec(`INSERT INTO account_data_events (id, kind, subject, outcome) VALUES ($1, 'deletion', $2, $3)++ ON CONFLICT (id) DO NOTHING`, [p.deletion_id, p.subject, JSON.stringify(counts)]);++ log.log(`[AccountData] ${service}: deletion ${p.deletion_id} ${JSON.stringify(counts)}`);++ rec = await db.maybe('SELECT * FROM account_data_events WHERE id = $1', [p.deletion_id]);++ result = 'erased';++ }++ if (rec.sent_at) return 'unchanged';++ const o = rec.outcome || {};++ const completedAt = new Date(rec.applied_at).toISOString();++ const res = await send(`/internal/account-deletions/${p.deletion_id}/confirmations`, { subject: p.subject, completed_at: completedAt, erased: o.erased || {}, retained: o.retained || {} });++ if (!res.ok && res.status !== 404) throw new Error(`openvibe-sdk/account-data: confirmation refused (${res.status})`);++ await db.exec('UPDATE account_data_events SET sent_at = now() WHERE id = $1', [p.deletion_id]);++ return result;++ }++++ /**++ * A request handler for POST /internal/events, for a service with no consumer of its own:++ * app.post('/internal/events', accountData.consumer({ secrets, send }))++ * (no body parser before it: the v2 signature covers the raw body). It verifies the signature under any of++ * `secrets`, answers 2xx once applied (Events retries anything else) and hands events of other topics to++ * `onEvent(event)` when given (otherwise they are acknowledged and ignored). Loopback only: a request that came++ * through a proxy (X-Forwarded-For, X-Real-IP, CF-Connecting-IP) is refused.++ */++ function consumer({ secrets = [], send, onEvent = null, now = () => Date.now(), limit = 256 * 1024 } = {}) {++ const keys = (secrets || []).filter((s) => typeof s === 'string' && s.length >= 32);++ const reply = (res, status, body) => {++ res.statusCode = status;++ res.setHeader('Content-Type', status < 300 ? 'application/json' : 'application/problem+json');++ res.setHeader('Cache-Control', 'no-store');++ res.end(JSON.stringify(body));++ };++ const problem = (res, status, code, detail) => reply(res, status, { type: 'about:blank', title: code, status, code, detail });++ const readRaw = (req) => (Buffer.isBuffer(req.body) ? Promise.resolve(req.body) : new Promise((resolve, reject) => {++ const chunks = [];++ let size = 0;++ req.on('data', (c) => { size += c.length; if (size > limit) { reject(Object.assign(new Error('too large'), { status: 413 })); req.destroy(); } else chunks.push(c); });++ req.on('end', () => resolve(Buffer.concat(chunks)));++ req.on('error', reject);++ }));++ return async function handle(req, res) {++ if (req.method !== 'POST') return problem(res, 405, 'method_not_allowed', 'POST only');++ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) return problem(res, 403, `${service}.internal_only`, 'internal route');++ if (!keys.length) return problem(res, 503, `${service}.events_disabled`, 'no events secret is configured');++ let raw;++ try { raw = await readRaw(req); } catch (err) { return problem(res, err.status || 400, 'bad_request', 'unreadable body'); }++ let delivery = null;++ for (const k of keys) { delivery = parseDelivery(raw, req.headers, k, { requireV2: true, now: now() }); if (delivery) break; }++ if (!delivery || !delivery.event || typeof delivery.event.event_type !== 'string') return problem(res, 401, `${service}.bad_signature`, 'X-OpenVibe-Signature-V2 does not verify or is outside the replay window');++ const ev = delivery.event;++ try {++ let outcome;++ if (TOPICS.includes(ev.event_type)) outcome = await apply(ev, { send });++ else if (onEvent) outcome = (await onEvent(ev)) || 'applied';++ else outcome = 'ignored:type';++ return reply(res, 200, { event_id: ev.event_id, outcome });++ } catch (err) {++ log.warn(`[AccountData] ${service}: ${ev.event_type} ${ev.event_id} failed: ${(err && err.message) || err}`);++ return problem(res, 503, `${service}.retry`, 'not applied; Events will retry');++ }++ };++ }++++ return { apply, exportPart, erase, ensureSchema, consumer, TOPICS, tables: entries.map((e) => ({ table: e.table, subject: e.subject, file: e.file, erase: e.erase })) };++}++++/**++ * send(path, body) → Response: POST to Network's internal routes with this service's own client-credentials token++ * (audience openvibe.network). A 401 refreshes the token once.++ */++function createNetworkSender({ networkInternalUrl, clientId, clientSecret, fetch: fetchImpl = globalThis.fetch, timeoutMs = 30000 } = {}) {++ if (!networkInternalUrl) throw new TypeError('createNetworkSender: networkInternalUrl is required');++ const base = String(networkInternalUrl).replace(/\/+$/, '');++ const tokens = createServiceTokenClient({ tokenUrl: `${base}/oauth/token`, clientId, clientSecret, audience: 'openvibe.network', fetch: fetchImpl });++ return async function send(path, body, retried = false) {++ const res = await fetchImpl(`${base}${path}`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', ...(await tokens.authHeaders()) },++ body: JSON.stringify(body),++ signal: AbortSignal.timeout(timeoutMs),++ });++ if (res.status === 401 && !retried) { tokens.invalidate(); return send(path, body, true); }++ return res;++ };++}++++module.exports = { createAccountData, createNetworkSender, ACCOUNT_DATA_SCHEMA, TOPICS };@@@ -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.35.2';++const SDK_VERSION = '0.36.0';module.exports = {createClient,nnew file mode 100644@@@ -0,0 +1,176 @@++'use strict';++/**++ * openvibe-sdk/account-data against real PostgreSQL (PGlite) and a stand-in Network: the export part, the erase++ * (delete, anonymize, keep, aliases), idempotency per export/deletion id, retries, and the signed consumer.++ */++const assert = require('node:assert/strict');++const http = require('node:http');++const { run, stubServer } = require('./helpers');++const { createDb } = require('../src/db');++const { signDeliveryHeaders } = require('../src/events');++const { createAccountData, createNetworkSender, ACCOUNT_DATA_SCHEMA, TOPICS } = require('../src/account-data');++++const A = 'usr_01JZ0000000000000000000AAA';++const B = 'usr_01JZ0000000000000000000BBB';++const OLD = 'usr_01JZ0000000000000000000MRG';++const EXP = 'exp_01JZ0000000000000000000EXP';++const DEL = 'del_01JZ0000000000000000000DEX';++// Fixture secrets, built so they never look like a real key to a scanner.++const SECRET = `whsec_${'fixture'.repeat(6)}`;++const WRONG = `whsec_${'mismatch'.repeat(5)}`;++const quiet = { log() {}, warn() {} };++++async function setup() {++ const db = createDb({ pglite: true });++ await db.exec(`CREATE TABLE plans (id TEXT PRIMARY KEY, owner TEXT, title TEXT, created_at TIMESTAMPTZ DEFAULT now());++ CREATE TABLE posts (id TEXT PRIMARY KEY, author TEXT, body TEXT, created_at TIMESTAMPTZ DEFAULT now());++ CREATE TABLE ledger (id TEXT PRIMARY KEY, subject TEXT, amount INT, secret_note TEXT)`);++ await db.exec(`INSERT INTO plans (id, owner, title, created_at) VALUES++ ('p1', 'user:${A}', 'week one', '2026-10-01T00:00:00Z'), ('p2', 'user:${A}', 'week two', '2026-10-02T00:00:00Z'),++ ('p3', 'user:${B}', 'not yours', '2026-10-02T00:00:00Z'), ('p4', 'user:${OLD}', 'from the merged account', '2026-09-01T00:00:00Z');++ INSERT INTO posts (id, author, body) VALUES ('s1', '${A}', 'hello'), ('s2', '${B}', 'reply');++ INSERT INTO ledger (id, subject, amount, secret_note) VALUES ('l1', '${A}', 5, 'staff only')`);++ const accountData = createAccountData({++ db, service: 'food', log: quiet, rowLimit: 1,++ tables: [++ { table: 'plans', subject: 'owner', value: (s) => `user:${s}`, file: 'plans.json' },++ { table: 'posts', subject: 'author', erase: { anonymize: { body: '[deleted]' } } },++ { table: 'ledger', subject: 'subject', columns: ['id', 'amount'], erase: { keep: 'a ledger' } },++ { table: 'not_created_yet', subject: 'owner' },++ ],++ });++ await accountData.ensureSchema();++ return { db, accountData };++}++++async function network({ partStatus = 201, confirmStatus = 201 } = {}) {++ const calls = [];++ const stub = await stubServer(async (req, res, body) => {++ if (req.url === '/oauth/token') { res.setHeader('Content-Type', 'application/json'); return res.end(JSON.stringify({ access_token: 'tok', expires_in: 300 })); }++ calls.push({ url: req.url, auth: req.headers.authorization, body: JSON.parse(body.toString() || 'null') });++ res.statusCode = req.url.includes('/parts') ? (typeof partStatus === 'function' ? partStatus() : partStatus) : (typeof confirmStatus === 'function' ? confirmStatus() : confirmStatus);++ res.end('{}');++ });++ const send = createNetworkSender({ networkInternalUrl: stub.url, clientId: 'food', clientSecret: 'secret' });++ return { stub, calls, send };++}++++const exportEvent = { event_id: 'evt_1', event_type: 'network.account.export_requested', source: 'network', payload: { export_id: EXP, subject: A } };++const deleteEvent = { event_id: 'evt_2', event_type: 'network.account.deleted', source: 'network', payload: { deletion_id: DEL, subject: A, aliases: [OLD] } };++++run([++ ['a table map that could inject SQL or write a bad file name is refused when it is made', async () => {++ const db = createDb({ pglite: true });++ assert.throws(() => createAccountData({ db, service: 'x', tables: [{ table: 'plans; DROP TABLE x', subject: 'owner' }] }), /plain identifier/);++ assert.throws(() => createAccountData({ db, service: 'x', tables: [{ table: 'plans', subject: 'owner', file: '../plans.json' }] }), /flat name/);++ assert.throws(() => createAccountData({ db, service: 'x', tables: [{ table: 'a', subject: 's', file: 'f.json' }, { table: 'b', subject: 's', file: 'f.json' }] }), /two tables/);++ assert.throws(() => createAccountData({ db, service: 'x', tables: [{ table: 'a', subject: 's', erase: 'shred' }] }), /erase is/);++ assert.deepEqual(TOPICS, ['network.account.export_requested', 'network.account.deleted']);++ assert.match(ACCOUNT_DATA_SCHEMA, /account_data_events/);++ await db.close();++ }],++++ ['the export part: the person\'s rows in their stored form, listed columns only, cut at the row limit, missing tables skipped', async () => {++ const { db, accountData } = await setup();++ const part = await accountData.exportPart(A);++ assert.equal(part.subject, A);++ assert.deepEqual(part.files.map((f) => f.name), ['plans.json', 'posts.json', 'ledger.json']);++ const plans = part.files.find((f) => f.name === 'plans.json').content;++ assert.equal(plans.length, 1, 'cut at rowLimit 1');++ assert.equal(plans[0].id, 'p2', 'newest first');++ assert.deepEqual(part.truncated, ['plans.json']);++ assert.deepEqual(Object.keys(part.files.find((f) => f.name === 'ledger.json').content[0]).sort(), ['amount', 'id'], 'only the listed columns');++ assert.ok(!JSON.stringify(part).includes('not yours'), 'nobody else\'s rows');++ await db.close();++ }],++++ ['an export is pushed once with the service token; a redelivery is unchanged; a closed export is closed; a refusal is retried', async () => {++ const { db, accountData } = await setup();++ const { stub, calls, send } = await network();++ assert.equal(await accountData.apply(exportEvent, { send }), 'exported');++ assert.equal(calls.length, 1);++ assert.equal(calls[0].url, `/internal/account-exports/${EXP}/parts`);++ assert.equal(calls[0].auth, 'Bearer tok');++ assert.equal(calls[0].body.subject, A);++ assert.equal(await accountData.apply(exportEvent, { send }), 'unchanged');++ assert.equal(calls.length, 1, 'not sent twice');++ await stub.close();++++ const late = await network({ partStatus: 409 });++ const other = { ...exportEvent, payload: { export_id: 'exp_01JZ0000000000000000000TAR', subject: A } };++ assert.equal(await accountData.apply(other, { send: late.send }), 'closed');++ await late.stub.close();++++ const down = await network({ partStatus: 500 });++ const third = { ...exportEvent, payload: { export_id: 'exp_01JZ0000000000000000000RTY', subject: A } };++ await assert.rejects(accountData.apply(third, { send: down.send }), /refused \(500\)/);++ await down.stub.close();++ await db.close();++ }],++++ ['a deletion erases the account and its merged aliases once, keeps what it must, confirms with counts, and retries only the confirmation', async () => {++ const { db, accountData } = await setup();++ let fail = true;++ const { stub, calls, send } = await network({ confirmStatus: () => (fail ? 503 : 201) });++ await assert.rejects(accountData.apply(deleteEvent, { send }), /confirmation refused \(503\)/);++ assert.equal(await db.value(`SELECT COUNT(*)::int FROM plans WHERE owner IN ('user:${A}', 'user:${OLD}')`), 0, 'both forms erased');++ assert.equal(await db.value(`SELECT COUNT(*)::int FROM plans WHERE owner = 'user:${B}'`), 1, 'someone else\'s plan stays');++ const post = await db.maybe("SELECT author, body FROM posts WHERE id = 's1'");++ assert.deepEqual(post, { author: null, body: '[deleted]' }, 'anonymized, not deleted');++ assert.equal(await db.value("SELECT COUNT(*)::int FROM ledger"), 1, 'the ledger is kept');++++ // Someone writes a new plan under the same id form before the redelivery: the redelivery must not erase again.++ await db.exec(`INSERT INTO plans (id, owner, title) VALUES ('p9', 'user:${A}', 'written later')`);++ fail = false;++ assert.equal(await accountData.apply(deleteEvent, { send }), 'confirmed');++ assert.equal(await db.value("SELECT COUNT(*)::int FROM plans WHERE id = 'p9'"), 1, 'not erased twice');++ const confirmation = calls[calls.length - 1];++ assert.equal(confirmation.url, `/internal/account-deletions/${DEL}/confirmations`);++ assert.deepEqual(confirmation.body.erased, { plans: 3 });++ assert.deepEqual(confirmation.body.retained, { tombstones: 1, ledger: 1 });++ assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));++ assert.equal(await accountData.apply(deleteEvent, { send }), 'unchanged');++ await stub.close();++ await db.close();++ }],++++ ['events that are not Network\'s account events, or carry a bad payload, are ignored', async () => {++ const { db, accountData } = await setup();++ const send = async () => { throw new Error('must not send'); };++ assert.equal(await accountData.apply({ event_type: 'live.stream.started', source: 'live' }, { send }), 'ignored:type');++ assert.equal(await accountData.apply({ ...exportEvent, source: 'chat' }, { send }), 'ignored:source');++ assert.equal(await accountData.apply({ ...exportEvent, payload: { export_id: 'nope', subject: A } }, { send }), 'ignored:payload');++ assert.equal(await accountData.apply({ ...deleteEvent, payload: { deletion_id: DEL, subject: 'user:1' } }, { send }), 'ignored:payload');++ await db.close();++ }],++++ ['the consumer: v2 signature only, loopback only, 503 without a secret, other topics to onEvent, a failure asks for a retry', async () => {++ const { db, accountData } = await setup();++ const { stub, send } = await network();++ const seen = [];++ const handler = accountData.consumer({ secrets: [SECRET], send, onEvent: (ev) => { seen.push(ev.event_type); return 'noted'; } });++ const bare = accountData.consumer({ secrets: [], send });++ const server = http.createServer((req, res) => (req.url === '/bare' ? bare(req, res) : handler(req, res)));++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const post = async (ev, { secret = SECRET, headers = {}, path = '/' } = {}) => {++ const body = JSON.stringify({ event: ev, seq: 1 });++ const res = await fetch(base + path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers }, body });++ return { status: res.status, json: await res.json() };++ };++ try {++ assert.equal((await post(exportEvent)).json.outcome, 'exported');++ assert.equal((await post(exportEvent, { secret: WRONG })).status, 401);++ assert.equal((await post(exportEvent, { headers: { 'X-Forwarded-For': '203.0.113.9' } })).status, 403);++ assert.equal((await post(exportEvent, { path: '/bare' })).status, 503);++ const other = await post({ event_id: 'evt_9', event_type: 'live.stream.started', source: 'live', payload: {} });++ assert.deepEqual([other.status, other.json.outcome, seen], [200, 'noted', ['live.stream.started']]);++ await stub.close(); // Network down: the delivery must be retried, not acknowledged++ const retry = await post({ ...exportEvent, payload: { export_id: 'exp_01JZ0000000000000000000DWN', subject: A } });++ assert.equal(retry.status, 503);++ } finally {++ await new Promise((r) => server.close(r));++ await db.close();++ }++ }],++]);@@@ -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(), ['./cache', './commerce', './cost', './db', './delivery', './events', './govern', './identity', './limits', './node', './openre', './placement', './pubsub', './queue', './resources', './runtime', './service', './sso', './storage', './telemetry', './testing', './usage', './valkey', './vip']);++ assert.deepEqual(blocked.sort(), ['./account-data', './cache', './commerce', './cost', './db', './delivery', './events', './govern', './identity', './limits', './node', './openre', './placement', './pubsub', './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`);@@@ -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, 42);++ assert.equal(subpaths.length, 43);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,48 @@++import type { IncomingMessage, ServerResponse } from 'http';++++/** A table holding a person's rows here. */++export interface AccountDataTable {++ table: string;++ /** The column naming the person. */++ subject: string;++ /** usr_… → what the subject column stores (default: the id itself); may return several forms. */++ value?: (subject: string) => string | string[];++ /** The export file (default '<table>.json'); null leaves the table out of the export. */++ file?: string | null;++ /** The exported columns (default: all). */++ columns?: string[];++ /** The export order column (default created_at, else the first column). */++ order?: string;++ /** 'delete' (default), { anonymize: { column: value } } (subject column NULL, row kept) or { keep: why }. */++ erase?: 'delete' | { anonymize: Record<string, unknown> } | { keep: string };++ /** The name the counts use (default: the table). */++ kind?: string;++}++export interface ExportFile { name: string; content: unknown }++export interface ExportPart { subject: string; files: ExportFile[]; truncated: string[]; note?: string }++export interface EraseCounts { erased: Record<string, number>; retained: Record<string, number> }++export type AccountDataOutcome = 'exported' | 'erased' | 'confirmed' | 'closed' | 'unchanged' | `ignored:${string}`;++export type NetworkSend = (path: string, body: unknown) => Promise<Response>;++export interface AccountData {++ apply(event: { event_type: string; source?: string; event_id?: string; payload?: unknown }, opts: { send: NetworkSend }): Promise<AccountDataOutcome>;++ exportPart(subject: string): Promise<ExportPart>;++ erase(subjects: string[]): Promise<EraseCounts>;++ ensureSchema(): Promise<void>;++ /** POST /internal/events handler (no body parser before it). */++ consumer(opts: { secrets: string[]; send: NetworkSend; onEvent?: (event: any) => Promise<string | void> | string | void; now?: () => number; limit?: number }): (req: IncomingMessage & Record<string, any>, res: ServerResponse) => Promise<void>;++ TOPICS: readonly string[];++ tables: { table: string; subject: string; file: string | null; erase: AccountDataTable['erase'] }[];++}++export function createAccountData(opts: {++ db: any;++ service: string;++ tables: AccountDataTable[];++ extraExport?: (db: any, subject: string) => Promise<ExportFile[] | void>;++ extraErase?: (tx: any, subjects: string[], counts: EraseCounts & { add(o: Record<string, number>, kind: string, n: number): void }) => Promise<void>;++ note?: string | null;++ rowLimit?: number;++ log?: { log(msg: string): void; warn(msg: string): void };++}): AccountData;++export function createNetworkSender(opts: { networkInternalUrl: string; clientId: string; clientSecret: string; fetch?: typeof fetch; timeoutMs?: number }): NetworkSend;++export const ACCOUNT_DATA_SCHEMA: string;++export const TOPICS: readonly string[];
@@ -3,6 +3,18 @@
@@ -3,6 +3,18 @@
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.36.0 (2026-10-08)
+
+- **`openvibe-sdk/account-data` (new, node): a service's share of account export and deletion (ADR-033).** Six services
+ implemented it by hand; every newer one (Actor, Food, Help, Work, Quest, Rent, Watch…) had none. A service now
+ declares the tables that hold a person's rows (`{ table, subject, value?, file?, columns?, erase? }`, with `erase`
+ `'delete'`, `{ anonymize: {…} }` or `{ keep: '<why>' }`) and gets `apply(event, { send })` for
+ network.account.export_requested / network.account.deleted, a signed `consumer()` handler for POST /internal/events,
+ `exportPart()`, `erase()` (one transaction, merged-account aliases included), `ensureSchema()` /
+ `ACCOUNT_DATA_SCHEMA` (account_data_events: idempotent per export/deletion id, so a redelivery never erases twice and a
+ failed confirmation is sent again) and `createNetworkSender()` (the service's own client-credentials token for
+ Network's internal routes). Identifiers in the table map are checked when it is made; values are always parameters.
+
## 0.35.2 (2026-10-08)
## 0.35.2 (2026-10-08)
- **The mock developer platform grants `services.release.manage|read` (`openvibe-sdk/testing`, patch):** openvibe-contracts
- **The mock developer platform grants `services.release.manage|read` (`openvibe-sdk/testing`, patch):** openvibe-contracts
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Supported browser and server clients for the OpenVibe platform.
> Supported browser and server clients for the OpenVibe platform.
-**Status:** alpha, v0.35.2 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
+**Status:** alpha, v0.36.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**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.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
@@ -174,6 +174,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
@@ -174,6 +174,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/bot` | both | `createBotClient(client, {actingSubject?})`: OpenVibe.Bot robots. `robots.list/get/create/update/delete/pairingCode`, `robots.operators.list/add/remove`, `robots.devices`, `robots.streaming.get/set`, `robots.audit`/`iterateAudit`, `robots.estop`/`clearEstop`, `robots.command` (`bot.command@1`), `devices.rotate/revoke`, public `profiles.list/get` and `kits.list/get`, `pair` (the agent's one-time code, never retried), `as(subject)`; a service acts for a person with `X-OV-Subject` (capabilities `bot.robot.read`/`manage`/`control`, `bot.device.connect`) |
| `openvibe-sdk/bot` | both | `createBotClient(client, {actingSubject?})`: OpenVibe.Bot robots. `robots.list/get/create/update/delete/pairingCode`, `robots.operators.list/add/remove`, `robots.devices`, `robots.streaming.get/set`, `robots.audit`/`iterateAudit`, `robots.estop`/`clearEstop`, `robots.command` (`bot.command@1`), `devices.rotate/revoke`, public `profiles.list/get` and `kits.list/get`, `pair` (the agent's one-time code, never retried), `as(subject)`; a service acts for a person with `X-OV-Subject` (capabilities `bot.robot.read`/`manage`/`control`, `bot.device.connect`) |
| `openvibe-sdk/search` | both | `createSearchClient(client, {actingSubject?})`: `query(text, {owner, type, lang, filter, facets, limit, cursor})`, `iterate(text, {…, max})`, `suggest(text, {owner, type, limit})`, `document(owner, type, id)` (null when missing or not visible) |
| `openvibe-sdk/search` | both | `createSearchClient(client, {actingSubject?})`: `query(text, {owner, type, lang, filter, facets, limit, cursor})`, `iterate(text, {…, max})`, `suggest(text, {owner, type, limit})`, `document(owner, type, id)` (null when missing or not visible) |
| `openvibe-sdk/limits` | node | `createActorLimiter({limits: {minute, hour, day}, actor?, maxActors?, onLimited?})` → `limits(name, {minute?, hour?, day?})` Express middleware: per-actor fixed windows at a capability boundary (service principal, else the signed-in person, else `ip:`), 429 problem+json `rate_limited` with Retry-After before the handler; `defaultActor(req)`, `.stats()` |
| `openvibe-sdk/limits` | node | `createActorLimiter({limits: {minute, hour, day}, actor?, maxActors?, onLimited?})` → `limits(name, {minute?, hour?, day?})` Express middleware: per-actor fixed windows at a capability boundary (service principal, else the signed-in person, else `ip:`), 429 problem+json `rate_limited` with Retry-After before the handler; `defaultActor(req)`, `.stats()` |
+| `openvibe-sdk/account-data` | node | `createAccountData({db, service, tables: [{table, subject, value?, file?, columns?, erase?}], extraExport?, extraErase?})` → `.apply(event, {send})`, `.consumer({secrets, send, onEvent?})` (a POST /internal/events handler), `.exportPart(subject)`, `.erase(subjects)`, `.ensureSchema()`; `createNetworkSender({networkInternalUrl, clientId, clientSecret})`: a service's share of account export and deletion (ADR-033), idempotent per export/deletion id |
| `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `exportMine()` |
| `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `exportMine()` |
| `openvibe-sdk/ai` | both | `createAiClient(client)`: `runs.create(workflow, input, {wait, idempotencyKey, …})`, `runs.get/list/cancel/retry/citations/addCitations/waitFor`, `chat/generate/summarize/classify/extract/enrich/embed` |
| `openvibe-sdk/ai` | both | `createAiClient(client)`: `runs.create(workflow, input, {wait, idempotencyKey, …})`, `runs.get/list/cancel/retry/citations/addCitations/waitFor`, `chat/generate/summarize/classify/extract/enrich/embed` |
| `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `retry`, `reference`, `unreference`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()`. Default origin: the Tools gateway's jobs facade |
| `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `retry`, `reference`, `unreference`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()`. Default origin: the Tools gateway's jobs facade |
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.35.2",
+ "version": "0.36.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",
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.35.2, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.36.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.35.2';
+const SDK_VERSION = '0.36.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/account-data.js. Do not edit.
+import cjs from '../src/account-data.js';
+
+export const {
+ createAccountData, createNetworkSender, ACCOUNT_DATA_SCHEMA, TOPICS,
+} = cjs;
+export default cjs;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.35.2",
+ "version": "0.36.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.35.2",
+ "version": "0.36.0",
"license": "MIT",
"license": "MIT",
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.35.2",
+ "version": "0.36.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",
@@ -87,6 +87,12 @@
@@ -87,6 +87,12 @@
"import": "./esm/limits.mjs",
"import": "./esm/limits.mjs",
"default": "./src/limits.js"
"default": "./src/limits.js"
},
},
+ "./account-data": {
+ "types": "./types/account-data.d.ts",
+ "browser": null,
+ "import": "./esm/account-data.mjs",
+ "default": "./src/account-data.js"
+ },
"./govern": {
"./govern": {
"types": "./types/govern.d.ts",
"types": "./types/govern.d.ts",
"browser": null,
"browser": null,
@@ -27,6 +27,7 @@ const ENTRIES = {
@@ -27,6 +27,7 @@ const ENTRIES = {
'bot.mjs': '../src/bot.js',
'bot.mjs': '../src/bot.js',
'search.mjs': '../src/search.js',
'search.mjs': '../src/search.js',
'limits.mjs': '../src/limits.js',
'limits.mjs': '../src/limits.js',
+ 'account-data.mjs': '../src/account-data.js',
'govern.mjs': '../src/govern.js',
'govern.mjs': '../src/govern.js',
'placement.mjs': '../src/placement.js',
'placement.mjs': '../src/placement.js',
'usage.mjs': '../src/usage.js',
'usage.mjs': '../src/usage.js',
new file mode 100644
new file mode 100644
@@ -0,0 +1,278 @@
@@ -0,0 +1,278 @@
+'use strict';
+/**
+ * openvibe-sdk/account-data — a service's share of account export and deletion (ADR-033).
+ *
+ * Network asks every service holding network.account.export.contribute and network.account.deletion.confirm for its
+ * part. network.account.export_requested wants the person's rows, pushed to POST /internal/account-exports/:id/parts
+ * (network.account-export-part@1). network.account.deleted wants them gone, or made authorless where other people's
+ * rows hang under them, and confirmed with counts at POST /internal/account-deletions/:id/confirmations
+ * (network.account-deletion-confirmation@1). Network waits for every holder of those grants, so a service adopts this
+ * module and its Events subscription first, and asks for the grants last.
+ *
+ * const { createAccountData, createNetworkSender } = require('openvibe-sdk/account-data');
+ * const accountData = createAccountData({ db, service: 'food', tables: [
+ * { table: 'food_plans', subject: 'owner', value: (usr) => `user:${usr}`, file: 'plans.json' },
+ * { table: 'food_pantry', subject: 'owner', value: (usr) => `user:${usr}`, file: 'pantry.json' },
+ * ] });
+ * await accountData.ensureSchema(); // or ACCOUNT_DATA_SCHEMA in a migration
+ * const send = createNetworkSender({ networkInternalUrl, clientId, clientSecret });
+ * app.use('/internal/events', accountData.consumer({ secrets: [process.env.FOOD_EVENTS_SECRET], send }));
+ * // or, inside a consumer the service already has: const outcome = await accountData.apply(event, { send });
+ *
+ * A table entry:
+ * table the table (a plain lower-case identifier)
+ * subject the column naming the person
+ * value usr_… → what that column stores (default: the id itself); may return an array of forms
+ * file the export file name (default '<table>.json'); null leaves the table out of the export
+ * columns the exported columns (default: all). List them when a table holds anything that is not the person's to
+ * read (a token, a secret, someone else's private note).
+ * order the export order column (default: created_at when the table has it, else the first column)
+ * erase 'delete' (default)
+ * | { anonymize: { <column>: <value>, … } } the row stays, the subject column becomes NULL and the listed
+ * columns take the values (e.g. { body: '[deleted]' }): for rows other people's rows hang under
+ * | { keep: '<why>' } nothing changes; counted as retained (a ledger the law makes us keep)
+ * kind the name the counts use (default: the table)
+ *
+ * `extraExport(db, subject)` → [{ name, content }] and `extraErase(t, subjects, counts)` cover what a table map
+ * cannot say (recounting cached totals, files in object storage); extraErase runs inside the erase transaction.
+ *
+ * apply() answers 'exported' | 'erased' | 'confirmed' | 'closed' | 'unchanged' | 'ignored:<why>', and throws when
+ * Network refuses for a reason worth retrying, so Events redelivers. It is idempotent per export and deletion id
+ * (account_data_events): a redelivered deletion never erases twice, and a confirmation that failed is sent again.
+ */
+const { parseDelivery } = require('./events');
+const { createServiceTokenClient } = require('./auth/tokens');
+
+const TOPICS = Object.freeze(['network.account.export_requested', 'network.account.deleted']);
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const EXPORT_RE = /^exp_[0-9A-HJKMNP-TV-Z]{26}$/;
+const DELETION_RE = /^del_[0-9A-HJKMNP-TV-Z]{26}$/;
+const IDENT = /^[a-z_][a-z0-9_]{0,62}$/;
+const FILE_RE = /^[a-z0-9][a-z0-9_.-]{0,62}\.json$/;
+const MAX_FILES = 64;
+
+const ACCOUNT_DATA_SCHEMA = `CREATE TABLE IF NOT EXISTS account_data_events (
+ id TEXT PRIMARY KEY,
+ kind TEXT NOT NULL,
+ subject TEXT NOT NULL,
+ outcome JSONB,
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ sent_at TIMESTAMPTZ
+)`;
+
+function ident(name, what) {
+ if (typeof name !== 'string' || !IDENT.test(name)) throw new TypeError(`openvibe-sdk/account-data: ${what} "${name}" is not a plain identifier`);
+ return name;
+}
+
+function normaliseTables(tables) {
+ if (!Array.isArray(tables)) throw new TypeError('openvibe-sdk/account-data: tables must be an array');
+ const files = new Set();
+ return tables.map((t) => {
+ const table = ident(t.table, 'table');
+ const subject = ident(t.subject, `${table}: subject column`);
+ const file = t.file === null ? null : (t.file || `${table}.json`);
+ if (file !== null) {
+ if (!FILE_RE.test(file)) throw new TypeError(`openvibe-sdk/account-data: ${table}: file "${file}" must be a flat name ending in .json`);
+ if (files.has(file)) throw new TypeError(`openvibe-sdk/account-data: two tables export to ${file}`);
+ files.add(file);
+ }
+ const columns = t.columns ? t.columns.map((c) => ident(c, `${table}: column`)) : null;
+ const order = t.order ? ident(t.order, `${table}: order column`) : null;
+ let erase = t.erase || 'delete';
+ if (erase !== 'delete') {
+ if (erase.anonymize) {
+ for (const c of Object.keys(erase.anonymize)) ident(c, `${table}: anonymize column`);
+ } else if (!(typeof erase.keep === 'string' && erase.keep)) {
+ throw new TypeError(`openvibe-sdk/account-data: ${table}: erase is 'delete', { anonymize: {…} } or { keep: '<why>' }`);
+ }
+ }
+ const value = typeof t.value === 'function' ? t.value : (s) => s;
+ return { table, subject, file, columns, order, erase, value, kind: t.kind || table };
+ });
+}
+
+function createAccountData({ db, service, tables, extraExport = null, extraErase = null, note = null, rowLimit = 5000, log = console } = {}) {
+ if (!db || typeof db.many !== 'function' || typeof db.tx !== 'function') throw new TypeError('openvibe-sdk/account-data: db must be an openvibe-sdk/db handle');
+ if (typeof service !== 'string' || !service) throw new TypeError('openvibe-sdk/account-data: service is required');
+ const entries = normaliseTables(tables || []);
+ const columnCache = new Map();
+
+ /** The table's columns, or null when the table does not exist (yet): a table a migration has not made is skipped. */
+ async function columnsOf(q, table) {
+ if (columnCache.has(table)) return columnCache.get(table);
+ const rows = await q.many('SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = $1', [table]);
+ const cols = rows.length ? rows.map((r) => r.column_name) : null;
+ if (cols) columnCache.set(table, cols);
+ return cols;
+ }
+
+ const formsOf = (entry, subjects) => [...new Set(subjects.flatMap((s) => [].concat(entry.value(s))).filter((v) => v != null).map(String))];
+
+ async function ensureSchema() { await db.exec(ACCOUNT_DATA_SCHEMA); }
+
+ /** The person's part: one file per table that has rows for them, newest first, cut at rowLimit. */
+ async function exportPart(subject) {
+ const files = [];
+ const truncated = [];
+ for (const e of entries) {
+ if (e.file === null) continue;
+ const cols = await columnsOf(db, e.table);
+ if (!cols || !cols.includes(e.subject)) continue;
+ const order = e.order || (cols.includes('created_at') ? 'created_at' : cols[0]);
+ const list = e.columns ? e.columns.filter((c) => cols.includes(c)) : null;
+ const select = list && list.length ? list.map((c) => `"${c}"`).join(', ') : '*';
+ const rows = await db.many(`SELECT ${select} FROM "${e.table}" WHERE "${e.subject}" = ANY($1::text[]) ORDER BY "${order}" DESC LIMIT ${Number(rowLimit) + 1}`, [formsOf(e, [subject])]);
+ if (!rows.length) continue;
+ if (rows.length > rowLimit) truncated.push(e.file);
+ files.push({ name: e.file, content: rows.slice(0, rowLimit) });
+ }
+ if (extraExport) for (const f of (await extraExport(db, subject)) || []) files.push(f);
+ if (files.length > MAX_FILES) throw new Error(`openvibe-sdk/account-data: ${files.length} export files; at most ${MAX_FILES}`);
+ const part = { subject, files, truncated };
+ if (note) part.note = String(note).slice(0, 500);
+ return part;
+ }
+
+ /** Erase everything the subjects (the account and the accounts merged into it) left here, in one transaction. */
+ async function erase(subjects) {
+ const erased = {};
+ const retained = {};
+ const add = (o, k, n) => { if (n) o[k] = (o[k] || 0) + n; };
+ await db.tx(async (t) => {
+ for (const e of entries) {
+ const cols = await columnsOf(t, e.table);
+ if (!cols || !cols.includes(e.subject)) continue;
+ const forms = formsOf(e, subjects);
+ if (e.erase === 'delete') {
+ add(erased, e.kind, await t.exec(`DELETE FROM "${e.table}" WHERE "${e.subject}" = ANY($1::text[])`, [forms]));
+ } else if (e.erase.anonymize) {
+ const sets = [`"${e.subject}" = NULL`];
+ const values = [forms];
+ for (const [c, v] of Object.entries(e.erase.anonymize)) {
+ if (!cols.includes(c)) continue;
+ values.push(v);
+ sets.push(`"${c}" = $${values.length}`);
+ }
+ add(retained, 'tombstones', await t.exec(`UPDATE "${e.table}" SET ${sets.join(', ')} WHERE "${e.subject}" = ANY($1::text[])`, values));
+ } else {
+ add(retained, e.kind, Number(await t.value(`SELECT COUNT(*)::int FROM "${e.table}" WHERE "${e.subject}" = ANY($1::text[])`, [forms])) || 0);
+ }
+ }
+ if (extraErase) await extraErase(t, subjects, { erased, retained, add });
+ });
+ return { erased, retained };
+ }
+
+ /** One envelope → an outcome (see the header); throws to be redelivered. */
+ async function apply(ev, { send } = {}) {
+ if (!ev || !TOPICS.includes(ev.event_type)) return 'ignored:type';
+ if (ev.source !== 'network') return 'ignored:source';
+ if (typeof send !== 'function') throw new TypeError('openvibe-sdk/account-data: apply needs { send } (createNetworkSender)');
+ const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};
+ if (ev.event_type === 'network.account.export_requested') {
+ if (!EXPORT_RE.test(String(p.export_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';
+ const seen = await db.maybe('SELECT sent_at FROM account_data_events WHERE id = $1', [p.export_id]);
+ if (seen && seen.sent_at) return 'unchanged';
+ const part = await exportPart(p.subject);
+ const res = await send(`/internal/account-exports/${p.export_id}/parts`, part);
+ // 404/409: the export was built at its deadline or no longer exists; a late part has nowhere to go.
+ const outcome = res.ok ? 'exported' : (res.status === 409 || res.status === 404 ? 'closed' : null);
+ if (!outcome) throw new Error(`openvibe-sdk/account-data: export part refused (${res.status})`);
+ await db.exec(`INSERT INTO account_data_events (id, kind, subject, outcome, sent_at) VALUES ($1, 'export', $2, $3, now())
+ ON CONFLICT (id) DO UPDATE SET outcome = excluded.outcome, sent_at = excluded.sent_at`,
+ [p.export_id, p.subject, JSON.stringify({ result: outcome, files: part.files.length })]);
+ return outcome;
+ }
+ if (!DELETION_RE.test(String(p.deletion_id || '')) || !SUBJECT_RE.test(String(p.subject || ''))) return 'ignored:payload';
+ let rec = await db.maybe('SELECT * FROM account_data_events WHERE id = $1', [p.deletion_id]);
+ let result = 'confirmed';
+ if (!rec) {
+ const subjects = [p.subject, ...(Array.isArray(p.aliases) ? p.aliases.filter((s) => SUBJECT_RE.test(String(s))) : [])];
+ const counts = await erase(subjects);
+ await db.exec(`INSERT INTO account_data_events (id, kind, subject, outcome) VALUES ($1, 'deletion', $2, $3)
+ ON CONFLICT (id) DO NOTHING`, [p.deletion_id, p.subject, JSON.stringify(counts)]);
+ log.log(`[AccountData] ${service}: deletion ${p.deletion_id} ${JSON.stringify(counts)}`);
+ rec = await db.maybe('SELECT * FROM account_data_events WHERE id = $1', [p.deletion_id]);
+ result = 'erased';
+ }
+ if (rec.sent_at) return 'unchanged';
+ const o = rec.outcome || {};
+ const completedAt = new Date(rec.applied_at).toISOString();
+ const res = await send(`/internal/account-deletions/${p.deletion_id}/confirmations`, { subject: p.subject, completed_at: completedAt, erased: o.erased || {}, retained: o.retained || {} });
+ if (!res.ok && res.status !== 404) throw new Error(`openvibe-sdk/account-data: confirmation refused (${res.status})`);
+ await db.exec('UPDATE account_data_events SET sent_at = now() WHERE id = $1', [p.deletion_id]);
+ return result;
+ }
+
+ /**
+ * A request handler for POST /internal/events, for a service with no consumer of its own:
+ * app.post('/internal/events', accountData.consumer({ secrets, send }))
+ * (no body parser before it: the v2 signature covers the raw body). It verifies the signature under any of
+ * `secrets`, answers 2xx once applied (Events retries anything else) and hands events of other topics to
+ * `onEvent(event)` when given (otherwise they are acknowledged and ignored). Loopback only: a request that came
+ * through a proxy (X-Forwarded-For, X-Real-IP, CF-Connecting-IP) is refused.
+ */
+ function consumer({ secrets = [], send, onEvent = null, now = () => Date.now(), limit = 256 * 1024 } = {}) {
+ const keys = (secrets || []).filter((s) => typeof s === 'string' && s.length >= 32);
+ const reply = (res, status, body) => {
+ res.statusCode = status;
+ res.setHeader('Content-Type', status < 300 ? 'application/json' : 'application/problem+json');
+ res.setHeader('Cache-Control', 'no-store');
+ res.end(JSON.stringify(body));
+ };
+ const problem = (res, status, code, detail) => reply(res, status, { type: 'about:blank', title: code, status, code, detail });
+ const readRaw = (req) => (Buffer.isBuffer(req.body) ? Promise.resolve(req.body) : new Promise((resolve, reject) => {
+ const chunks = [];
+ let size = 0;
+ req.on('data', (c) => { size += c.length; if (size > limit) { reject(Object.assign(new Error('too large'), { status: 413 })); req.destroy(); } else chunks.push(c); });
+ req.on('end', () => resolve(Buffer.concat(chunks)));
+ req.on('error', reject);
+ }));
+ return async function handle(req, res) {
+ if (req.method !== 'POST') return problem(res, 405, 'method_not_allowed', 'POST only');
+ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) return problem(res, 403, `${service}.internal_only`, 'internal route');
+ if (!keys.length) return problem(res, 503, `${service}.events_disabled`, 'no events secret is configured');
+ let raw;
+ try { raw = await readRaw(req); } catch (err) { return problem(res, err.status || 400, 'bad_request', 'unreadable body'); }
+ let delivery = null;
+ for (const k of keys) { delivery = parseDelivery(raw, req.headers, k, { requireV2: true, now: now() }); if (delivery) break; }
+ if (!delivery || !delivery.event || typeof delivery.event.event_type !== 'string') return problem(res, 401, `${service}.bad_signature`, 'X-OpenVibe-Signature-V2 does not verify or is outside the replay window');
+ const ev = delivery.event;
+ try {
+ let outcome;
+ if (TOPICS.includes(ev.event_type)) outcome = await apply(ev, { send });
+ else if (onEvent) outcome = (await onEvent(ev)) || 'applied';
+ else outcome = 'ignored:type';
+ return reply(res, 200, { event_id: ev.event_id, outcome });
+ } catch (err) {
+ log.warn(`[AccountData] ${service}: ${ev.event_type} ${ev.event_id} failed: ${(err && err.message) || err}`);
+ return problem(res, 503, `${service}.retry`, 'not applied; Events will retry');
+ }
+ };
+ }
+
+ return { apply, exportPart, erase, ensureSchema, consumer, TOPICS, tables: entries.map((e) => ({ table: e.table, subject: e.subject, file: e.file, erase: e.erase })) };
+}
+
+/**
+ * send(path, body) → Response: POST to Network's internal routes with this service's own client-credentials token
+ * (audience openvibe.network). A 401 refreshes the token once.
+ */
+function createNetworkSender({ networkInternalUrl, clientId, clientSecret, fetch: fetchImpl = globalThis.fetch, timeoutMs = 30000 } = {}) {
+ if (!networkInternalUrl) throw new TypeError('createNetworkSender: networkInternalUrl is required');
+ const base = String(networkInternalUrl).replace(/\/+$/, '');
+ const tokens = createServiceTokenClient({ tokenUrl: `${base}/oauth/token`, clientId, clientSecret, audience: 'openvibe.network', fetch: fetchImpl });
+ return async function send(path, body, retried = false) {
+ const res = await fetchImpl(`${base}${path}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...(await tokens.authHeaders()) },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ if (res.status === 401 && !retried) { tokens.invalidate(); return send(path, body, true); }
+ return res;
+ };
+}
+
+module.exports = { createAccountData, createNetworkSender, ACCOUNT_DATA_SCHEMA, TOPICS };
@@ -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.35.2';
+const SDK_VERSION = '0.36.0';
module.exports = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,176 @@
@@ -0,0 +1,176 @@
+'use strict';
+/**
+ * openvibe-sdk/account-data against real PostgreSQL (PGlite) and a stand-in Network: the export part, the erase
+ * (delete, anonymize, keep, aliases), idempotency per export/deletion id, retries, and the signed consumer.
+ */
+const assert = require('node:assert/strict');
+const http = require('node:http');
+const { run, stubServer } = require('./helpers');
+const { createDb } = require('../src/db');
+const { signDeliveryHeaders } = require('../src/events');
+const { createAccountData, createNetworkSender, ACCOUNT_DATA_SCHEMA, TOPICS } = require('../src/account-data');
+
+const A = 'usr_01JZ0000000000000000000AAA';
+const B = 'usr_01JZ0000000000000000000BBB';
+const OLD = 'usr_01JZ0000000000000000000MRG';
+const EXP = 'exp_01JZ0000000000000000000EXP';
+const DEL = 'del_01JZ0000000000000000000DEX';
+// Fixture secrets, built so they never look like a real key to a scanner.
+const SECRET = `whsec_${'fixture'.repeat(6)}`;
+const WRONG = `whsec_${'mismatch'.repeat(5)}`;
+const quiet = { log() {}, warn() {} };
+
+async function setup() {
+ const db = createDb({ pglite: true });
+ await db.exec(`CREATE TABLE plans (id TEXT PRIMARY KEY, owner TEXT, title TEXT, created_at TIMESTAMPTZ DEFAULT now());
+ CREATE TABLE posts (id TEXT PRIMARY KEY, author TEXT, body TEXT, created_at TIMESTAMPTZ DEFAULT now());
+ CREATE TABLE ledger (id TEXT PRIMARY KEY, subject TEXT, amount INT, secret_note TEXT)`);
+ await db.exec(`INSERT INTO plans (id, owner, title, created_at) VALUES
+ ('p1', 'user:${A}', 'week one', '2026-10-01T00:00:00Z'), ('p2', 'user:${A}', 'week two', '2026-10-02T00:00:00Z'),
+ ('p3', 'user:${B}', 'not yours', '2026-10-02T00:00:00Z'), ('p4', 'user:${OLD}', 'from the merged account', '2026-09-01T00:00:00Z');
+ INSERT INTO posts (id, author, body) VALUES ('s1', '${A}', 'hello'), ('s2', '${B}', 'reply');
+ INSERT INTO ledger (id, subject, amount, secret_note) VALUES ('l1', '${A}', 5, 'staff only')`);
+ const accountData = createAccountData({
+ db, service: 'food', log: quiet, rowLimit: 1,
+ tables: [
+ { table: 'plans', subject: 'owner', value: (s) => `user:${s}`, file: 'plans.json' },
+ { table: 'posts', subject: 'author', erase: { anonymize: { body: '[deleted]' } } },
+ { table: 'ledger', subject: 'subject', columns: ['id', 'amount'], erase: { keep: 'a ledger' } },
+ { table: 'not_created_yet', subject: 'owner' },
+ ],
+ });
+ await accountData.ensureSchema();
+ return { db, accountData };
+}
+
+async function network({ partStatus = 201, confirmStatus = 201 } = {}) {
+ const calls = [];
+ const stub = await stubServer(async (req, res, body) => {
+ if (req.url === '/oauth/token') { res.setHeader('Content-Type', 'application/json'); return res.end(JSON.stringify({ access_token: 'tok', expires_in: 300 })); }
+ calls.push({ url: req.url, auth: req.headers.authorization, body: JSON.parse(body.toString() || 'null') });
+ res.statusCode = req.url.includes('/parts') ? (typeof partStatus === 'function' ? partStatus() : partStatus) : (typeof confirmStatus === 'function' ? confirmStatus() : confirmStatus);
+ res.end('{}');
+ });
+ const send = createNetworkSender({ networkInternalUrl: stub.url, clientId: 'food', clientSecret: 'secret' });
+ return { stub, calls, send };
+}
+
+const exportEvent = { event_id: 'evt_1', event_type: 'network.account.export_requested', source: 'network', payload: { export_id: EXP, subject: A } };
+const deleteEvent = { event_id: 'evt_2', event_type: 'network.account.deleted', source: 'network', payload: { deletion_id: DEL, subject: A, aliases: [OLD] } };
+
+run([
+ ['a table map that could inject SQL or write a bad file name is refused when it is made', async () => {
+ const db = createDb({ pglite: true });
+ assert.throws(() => createAccountData({ db, service: 'x', tables: [{ table: 'plans; DROP TABLE x', subject: 'owner' }] }), /plain identifier/);
+ assert.throws(() => createAccountData({ db, service: 'x', tables: [{ table: 'plans', subject: 'owner', file: '../plans.json' }] }), /flat name/);
+ assert.throws(() => createAccountData({ db, service: 'x', tables: [{ table: 'a', subject: 's', file: 'f.json' }, { table: 'b', subject: 's', file: 'f.json' }] }), /two tables/);
+ assert.throws(() => createAccountData({ db, service: 'x', tables: [{ table: 'a', subject: 's', erase: 'shred' }] }), /erase is/);
+ assert.deepEqual(TOPICS, ['network.account.export_requested', 'network.account.deleted']);
+ assert.match(ACCOUNT_DATA_SCHEMA, /account_data_events/);
+ await db.close();
+ }],
+
+ ['the export part: the person\'s rows in their stored form, listed columns only, cut at the row limit, missing tables skipped', async () => {
+ const { db, accountData } = await setup();
+ const part = await accountData.exportPart(A);
+ assert.equal(part.subject, A);
+ assert.deepEqual(part.files.map((f) => f.name), ['plans.json', 'posts.json', 'ledger.json']);
+ const plans = part.files.find((f) => f.name === 'plans.json').content;
+ assert.equal(plans.length, 1, 'cut at rowLimit 1');
+ assert.equal(plans[0].id, 'p2', 'newest first');
+ assert.deepEqual(part.truncated, ['plans.json']);
+ assert.deepEqual(Object.keys(part.files.find((f) => f.name === 'ledger.json').content[0]).sort(), ['amount', 'id'], 'only the listed columns');
+ assert.ok(!JSON.stringify(part).includes('not yours'), 'nobody else\'s rows');
+ await db.close();
+ }],
+
+ ['an export is pushed once with the service token; a redelivery is unchanged; a closed export is closed; a refusal is retried', async () => {
+ const { db, accountData } = await setup();
+ const { stub, calls, send } = await network();
+ assert.equal(await accountData.apply(exportEvent, { send }), 'exported');
+ assert.equal(calls.length, 1);
+ assert.equal(calls[0].url, `/internal/account-exports/${EXP}/parts`);
+ assert.equal(calls[0].auth, 'Bearer tok');
+ assert.equal(calls[0].body.subject, A);
+ assert.equal(await accountData.apply(exportEvent, { send }), 'unchanged');
+ assert.equal(calls.length, 1, 'not sent twice');
+ await stub.close();
+
+ const late = await network({ partStatus: 409 });
+ const other = { ...exportEvent, payload: { export_id: 'exp_01JZ0000000000000000000TAR', subject: A } };
+ assert.equal(await accountData.apply(other, { send: late.send }), 'closed');
+ await late.stub.close();
+
+ const down = await network({ partStatus: 500 });
+ const third = { ...exportEvent, payload: { export_id: 'exp_01JZ0000000000000000000RTY', subject: A } };
+ await assert.rejects(accountData.apply(third, { send: down.send }), /refused \(500\)/);
+ await down.stub.close();
+ await db.close();
+ }],
+
+ ['a deletion erases the account and its merged aliases once, keeps what it must, confirms with counts, and retries only the confirmation', async () => {
+ const { db, accountData } = await setup();
+ let fail = true;
+ const { stub, calls, send } = await network({ confirmStatus: () => (fail ? 503 : 201) });
+ await assert.rejects(accountData.apply(deleteEvent, { send }), /confirmation refused \(503\)/);
+ assert.equal(await db.value(`SELECT COUNT(*)::int FROM plans WHERE owner IN ('user:${A}', 'user:${OLD}')`), 0, 'both forms erased');
+ assert.equal(await db.value(`SELECT COUNT(*)::int FROM plans WHERE owner = 'user:${B}'`), 1, 'someone else\'s plan stays');
+ const post = await db.maybe("SELECT author, body FROM posts WHERE id = 's1'");
+ assert.deepEqual(post, { author: null, body: '[deleted]' }, 'anonymized, not deleted');
+ assert.equal(await db.value("SELECT COUNT(*)::int FROM ledger"), 1, 'the ledger is kept');
+
+ // Someone writes a new plan under the same id form before the redelivery: the redelivery must not erase again.
+ await db.exec(`INSERT INTO plans (id, owner, title) VALUES ('p9', 'user:${A}', 'written later')`);
+ fail = false;
+ assert.equal(await accountData.apply(deleteEvent, { send }), 'confirmed');
+ assert.equal(await db.value("SELECT COUNT(*)::int FROM plans WHERE id = 'p9'"), 1, 'not erased twice');
+ const confirmation = calls[calls.length - 1];
+ assert.equal(confirmation.url, `/internal/account-deletions/${DEL}/confirmations`);
+ assert.deepEqual(confirmation.body.erased, { plans: 3 });
+ assert.deepEqual(confirmation.body.retained, { tombstones: 1, ledger: 1 });
+ assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));
+ assert.equal(await accountData.apply(deleteEvent, { send }), 'unchanged');
+ await stub.close();
+ await db.close();
+ }],
+
+ ['events that are not Network\'s account events, or carry a bad payload, are ignored', async () => {
+ const { db, accountData } = await setup();
+ const send = async () => { throw new Error('must not send'); };
+ assert.equal(await accountData.apply({ event_type: 'live.stream.started', source: 'live' }, { send }), 'ignored:type');
+ assert.equal(await accountData.apply({ ...exportEvent, source: 'chat' }, { send }), 'ignored:source');
+ assert.equal(await accountData.apply({ ...exportEvent, payload: { export_id: 'nope', subject: A } }, { send }), 'ignored:payload');
+ assert.equal(await accountData.apply({ ...deleteEvent, payload: { deletion_id: DEL, subject: 'user:1' } }, { send }), 'ignored:payload');
+ await db.close();
+ }],
+
+ ['the consumer: v2 signature only, loopback only, 503 without a secret, other topics to onEvent, a failure asks for a retry', async () => {
+ const { db, accountData } = await setup();
+ const { stub, send } = await network();
+ const seen = [];
+ const handler = accountData.consumer({ secrets: [SECRET], send, onEvent: (ev) => { seen.push(ev.event_type); return 'noted'; } });
+ const bare = accountData.consumer({ secrets: [], send });
+ const server = http.createServer((req, res) => (req.url === '/bare' ? bare(req, res) : handler(req, res)));
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const post = async (ev, { secret = SECRET, headers = {}, path = '/' } = {}) => {
+ const body = JSON.stringify({ event: ev, seq: 1 });
+ const res = await fetch(base + path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, secret), ...headers }, body });
+ return { status: res.status, json: await res.json() };
+ };
+ try {
+ assert.equal((await post(exportEvent)).json.outcome, 'exported');
+ assert.equal((await post(exportEvent, { secret: WRONG })).status, 401);
+ assert.equal((await post(exportEvent, { headers: { 'X-Forwarded-For': '203.0.113.9' } })).status, 403);
+ assert.equal((await post(exportEvent, { path: '/bare' })).status, 503);
+ const other = await post({ event_id: 'evt_9', event_type: 'live.stream.started', source: 'live', payload: {} });
+ assert.deepEqual([other.status, other.json.outcome, seen], [200, 'noted', ['live.stream.started']]);
+ await stub.close(); // Network down: the delivery must be retried, not acknowledged
+ const retry = await post({ ...exportEvent, payload: { export_id: 'exp_01JZ0000000000000000000DWN', subject: A } });
+ assert.equal(retry.status, 503);
+ } finally {
+ await new Promise((r) => server.close(r));
+ await db.close();
+ }
+ }],
+]);
@@ -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(), ['./cache', './commerce', './cost', './db', './delivery', './events', './govern', './identity', './limits', './node', './openre', './placement', './pubsub', './queue', './resources', './runtime', './service', './sso', './storage', './telemetry', './testing', './usage', './valkey', './vip']);
+ assert.deepEqual(blocked.sort(), ['./account-data', './cache', './commerce', './cost', './db', './delivery', './events', './govern', './identity', './limits', './node', './openre', './placement', './pubsub', './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`);
@@ -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, 42);
+ assert.equal(subpaths.length, 43);
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,48 @@
@@ -0,0 +1,48 @@
+import type { IncomingMessage, ServerResponse } from 'http';
+
+/** A table holding a person's rows here. */
+export interface AccountDataTable {
+ table: string;
+ /** The column naming the person. */
+ subject: string;
+ /** usr_… → what the subject column stores (default: the id itself); may return several forms. */
+ value?: (subject: string) => string | string[];
+ /** The export file (default '<table>.json'); null leaves the table out of the export. */
+ file?: string | null;
+ /** The exported columns (default: all). */
+ columns?: string[];
+ /** The export order column (default created_at, else the first column). */
+ order?: string;
+ /** 'delete' (default), { anonymize: { column: value } } (subject column NULL, row kept) or { keep: why }. */
+ erase?: 'delete' | { anonymize: Record<string, unknown> } | { keep: string };
+ /** The name the counts use (default: the table). */
+ kind?: string;
+}
+export interface ExportFile { name: string; content: unknown }
+export interface ExportPart { subject: string; files: ExportFile[]; truncated: string[]; note?: string }
+export interface EraseCounts { erased: Record<string, number>; retained: Record<string, number> }
+export type AccountDataOutcome = 'exported' | 'erased' | 'confirmed' | 'closed' | 'unchanged' | `ignored:${string}`;
+export type NetworkSend = (path: string, body: unknown) => Promise<Response>;
+export interface AccountData {
+ apply(event: { event_type: string; source?: string; event_id?: string; payload?: unknown }, opts: { send: NetworkSend }): Promise<AccountDataOutcome>;
+ exportPart(subject: string): Promise<ExportPart>;
+ erase(subjects: string[]): Promise<EraseCounts>;
+ ensureSchema(): Promise<void>;
+ /** POST /internal/events handler (no body parser before it). */
+ consumer(opts: { secrets: string[]; send: NetworkSend; onEvent?: (event: any) => Promise<string | void> | string | void; now?: () => number; limit?: number }): (req: IncomingMessage & Record<string, any>, res: ServerResponse) => Promise<void>;
+ TOPICS: readonly string[];
+ tables: { table: string; subject: string; file: string | null; erase: AccountDataTable['erase'] }[];
+}
+export function createAccountData(opts: {
+ db: any;
+ service: string;
+ tables: AccountDataTable[];
+ extraExport?: (db: any, subject: string) => Promise<ExportFile[] | void>;
+ extraErase?: (tx: any, subjects: string[], counts: EraseCounts & { add(o: Record<string, number>, kind: string, n: number): void }) => Promise<void>;
+ note?: string | null;
+ rowLimit?: number;
+ log?: { log(msg: string): void; warn(msg: string): void };
+}): AccountData;
+export function createNetworkSender(opts: { networkInternalUrl: string; clientId: string; clientSecret: string; fetch?: typeof fetch; timeoutMs?: number }): NetworkSend;
+export const ACCOUNT_DATA_SCHEMA: string;
+export const TOPICS: readonly string[];