openvibers/openvibe.sdk Commits

0.78.0: openvibe-sdk/consent — ask before using a person's content (#106)

OP OpenVibers committed 13 days ago · df1e8e83ff · 16 files, +659 −14

Adds the L24 consent client: allowed() and filter() (batches of
500) ask Network's /internal/consent/check for a purpose, kind, client
or feature; onChange() drops a subject's answers when
network.consent.changed arrives; mock() answers from a map in tests.
It fails closed on a network error, the 2 s timeout, a non-200, a
malformed answer or a token that cannot be minted, caches for 60 s,
and logs at most once a minute. Types, the ESM entry and the API
surface are included; docs/consent.md has the dataset-build example.
Bumps the package to 0.78.0 (not tagged).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 18dadb7..00fca03 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,22 @@
# Changelog
++## 0.78.0 (2026-10-31)
++
++- **`openvibe-sdk/consent`:** the client for the L24 consent registry. Nothing a person makes is off-limits to AI or
++ data use by design; each use is off until that person turns it on in OpenVibe.Network. A service asks this check
++ before it uses a person's content or likeness.
++ - `createConsent({ networkUrl, token, ttlMs = 60_000, fetch, log })`: `token` returns a service token holding
++ `network.consent.read`. `allowed(subject, purpose, { kind, client, feature })` → a boolean;
++ `filter(subjects, purpose, opts)` → the allowed subset, in input order, in batches of 500;
++ `onChange(event)` drops a subject's answers for a purpose on `network.consent.changed` (wire the payload or the
++ envelope); `mock(map)` answers from `{ subject: ['purpose'] }` without the network.
++ - **Fails closed:** a network error, the 2 s timeout, a non-200, a malformed answer or a token that cannot be
++ minted is false for everyone asked. A failure is never cached as a yes; it logs at most once a minute.
++ - An answer is cached for `ttlMs` (60 s), per subject, purpose, kind, client and feature; an answer in flight when
++ a change arrives is not stored, so a revocation cannot be overwritten.
++ - Server-side (`browser: null`): the token is a service token. Types are in `types/consent.d.ts`, the dataset-build
++ example in `docs/consent.md`.
++
## 0.77.0 (2026-10-31)
- **AI text may describe people** (the owner, 2026-10-31: "there should be no reason it can't describe people … remove
ddiff --git a/README.md b/README.md
iindex a3dcd43..ed6622b 100644
---- a/README.md
++++ b/README.md
@@@ -2,16 +2,16 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.77.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI and Vision clients; sign-in with recovery and a client health check). 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.78.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). 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.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
--After v0.77.0 is tagged:
++After v0.78.0 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.77.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.78.0
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from Contracts v0.86.0, a devDependency the tests check them against).
@@@ -178,6 +178,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/themes` | both | `createThemesClient(client)`: `catalog({mode, search, sort, limit})` (seasonal themes carry `season`), `get(idOrSlug)` (null when missing), `export(idOrSlug)`, `active()` (`{theme_id, custom_variables, display}`), `set(themeId, {custom})`, `setDisplay({motion, text, seasonal})`, `seasons({date})`, `submissions()`, `submit(theme)`, `import(file)`. In the browser: `followTheme(fn)` calls back now and on every theme, display or season change with `readTheme()` (`{id, mode, tokens, display: {motion, text, textScale, seasonal, calm}, season, seasonMark}`) and returns `stop()`, for a canvas or game to keep up; `applyTheme(vars, {root})`, `seasonOn(date, seasons)`, `onColor(hex)`, `DISPLAY_OPTIONS`, `TOKENS` |
| `openvibe-sdk/bench` | both | `createBenchClient(client, {actingSubject?})`: `kinds.list/get`, `tasks.create/get`, `rounds.create/get`, `entries.submit`, `judgements.next/cast`, `boards.get`, `models.get`, `datasets.get/iterate`, `evals.create/get/run/results`, `mcpTools()`. Audience `openvibe.bench`; service id `bench`. |
| `openvibe-sdk/vision` | both | `createVisionClient(client)`: image questions, OCR, private search, network tools, UI checks, consent and face privacy, and `/mcp`. Audience `openvibe.vision`; service id `vision`. |
++| `openvibe-sdk/consent` | server | `createConsent({networkUrl, token, ttlMs?})`: the L24 check before any use of a person's content or likeness for AI or data. `allowed(subject, purpose, {kind, client, feature})` → boolean, `filter(subjects, purpose, opts)` → the allowed subset (batches of 500, input order), `onChange(event)` drops that subject's answers for the purpose on `network.consent.changed`, `mock(map)` for tests. **Fails closed** (network error, 2 s timeout, non-200 or malformed answer → false); caches 60 s; logs at most once a minute. [Example](docs/consent.md) |
| `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})`; `startSubscriptions({eventsUrl, endpoint, secret, networkInternalUrl, clientId, clientSecret})` (the two Events subscriptions, created at boot when missing): a service's share of account export and deletion (ADR-033), idempotent per export/deletion id; telemetry is covered once here (the person's `telemetry_samples` exported as `telemetry.json`, its `subject` set to NULL and the sample kept) unless the table is listed; `uncovered(db, {tables, taskKeyed?, exempt?})` lists the tables with a person column (`subject`, `owner`, `created_by`, `user_id`, `person`) that nothing covers, for a service test to assert is empty |
ddiff --git a/STATUS.json b/STATUS.json
iindex d636381..896eb33 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.77.0",
++ "version": "0.78.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), \u00a73.2; roadmap \u00a730",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@@ -33,7 +33,8 @@
"the telemetry sink (openvibe-sdk/telemetry): telemetrySchema() DDL, createPgTelemetrySink() and pruneTelemetry(), a per-service PostgreSQL table every service stores platform.telemetry-sample@1 into",
"account-data (openvibe-sdk/account-data, ADR-033): telemetry_samples covered once by the SDK (exported as telemetry.json, anonymized on deletion), and uncovered() listing every table with a person column that no table map, task-keyed list or reasoned exemption covers",
"resources (openvibe-sdk/resources): the ADR-048 resource index fan-out and the idempotent control client",
-- "placement v1 (openvibe-sdk/placement): place() with the person's rules and ownership, the quality bar, cost per success, learn/successOf, escalate and the one-line reason; plan() takes the optional success map and never defaults to user-owned"
++ "placement v1 (openvibe-sdk/placement): place() with the person's rules and ownership, the quality bar, cost per success, learn/successOf, escalate and the one-line reason; plan() takes the optional success map and never defaults to user-owned",
++ "consent (openvibe-sdk/consent): the L24 check before any use of a person's content or likeness for AI or data; allowed() and filter() (batches of 500), onChange() drops a subject's answers on network.consent.changed, mock() for tests; fails closed, caches 60 s, server-side"
],
-- "updated": "2026-10-30"
++ "updated": "2026-10-31"
}
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 4de2d33..597a1a0 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.77.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.78.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, bench, vision, themes, redact, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -46,7 +46,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.77.0';
++const SDK_VERSION = '0.78.0';
module.exports = {
createClient,
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex 09f4bdd..613f1e5 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -420,6 +420,15 @@
"actingHeaders": "function",
"createCommunityClient": "function"
},
++ "openvibe-sdk/consent": {
++ "DEFAULT_TIMEOUT_MS": "number",
++ "DEFAULT_TTL_MS": "number",
++ "FEATURES": "array",
++ "KINDS": "array",
++ "MAX_SUBJECTS": "number",
++ "PURPOSES": "array",
++ "createConsent": "function"
++ },
"openvibe-sdk/core": {
"CONTRACTS_RANGE": "string",
"DEFAULT_NETWORK": "string",
ddiff --git a/docs/consent.md b/docs/consent.md
nnew file mode 100644
iindex 0000000..375401b
---- /dev/null
++++ b/docs/consent.md
@@@ -0,0 +1,66 @@
++# `openvibe-sdk/consent` — ask before using a person's content
++
++**L24, 2026-10-31.** Nothing a person makes is off-limits to AI or data use by design. Each use is **off until
++that person turns it on** in OpenVibe.Network. A service asks this check before it uses a person's content or
++likeness, and treats every doubt as "off".
++
++```js
++const { createConsent } = require('openvibe-sdk/consent');
++const { createServiceTokenClient } = require('openvibe-sdk/auth');
++
++const tokens = createServiceTokenClient({
++ clientId: 'ai', clientSecret: process.env.OV_CLIENT_SECRET, audience: 'openvibe.network',
++});
++const consent = createConsent({ networkUrl: 'https://openvibe.network', token: () => tokens.getToken() });
++
++// A dataset build: keep only the rows whose author turned ai.train on for that kind of content.
++const rows = await loadCandidateRows();
++const authors = [...new Set(rows.map((row) => row.author))];
++const allowed = new Set(await consent.filter(authors, 'ai.train', { kind: 'posts' }));
++const build = rows.filter((row) => allowed.has(row.author));
++```
++
++`filter` asks in batches of 500 (the check's limit), keeps input order and drops duplicates. `allowed(subject,
++purpose, opts)` is the single-person form:
++
++```js
++if (await consent.allowed(row.author, 'data.public', { kind: 'pictures' })) {
++ publish(row); // the licence the person chose is recorded with the dataset release
++}
++```
++
++Each answer is true **only** for an active choice that covers the purpose, the kind (empty kinds cover every kind),
++the client (`app.data` names the app) and the feature (`face.recognize`: `verify`, `find`, `cast` or `library`). The
++purposes are `ai.train`, `data.public`, `face.recognize` and `app.data`.
++
++## The cache, and the events
++
++An answer is cached for `ttlMs` (60 000 by default), per subject, purpose, kind, client and feature — a different
++question is a different entry. Wire the change event where the service receives Events:
++
++```js
++// In the handler that receives the network.consent.changed event (Events client, worker or webhook):
++consent.onChange(event); // drops that subject's answers for that purpose at once
++// …and if a use is running for them, check again before it goes on.
++```
++
++`onChange` accepts the event envelope, its payload, or a bare `{ subject, purpose }`; with neither it clears the
++whole cache. An answer that was already in flight when the change arrived is **not** stored, so a revocation cannot
++be overwritten by a stale response.
++
++## Fails closed
++
++A network error, the 2 s timeout, a non-200, a malformed answer or a token that cannot be minted is `false` for
++everyone asked. A failure is never cached as a yes; the next call asks again. It logs at most once a minute (pass
++`log: null` to silence it). The service-side rule is the same: when Network cannot answer, every purpose is off.
++
++## Tests
++
++```js
++consent.mock({ usr_01JAB2C3D4E5F6G7H8J9K0MNPA: ['ai.train', 'data.public'] });
++await consent.allowed('usr_01JAB2C3D4E5F6G7H8J9K0MNPA', 'ai.train'); // true, no network
++consent.mock(null); // back to the live check
++```
++
++`createConsent` is server-side: `token` is a service token for OpenVibe.Network with `network.consent.read`.
++Options: `ttlMs`, `fetch`, `log`, and (for tests) `timeoutMs` and `now`.
ddiff --git a/esm/consent.mjs b/esm/consent.mjs
nnew file mode 100644
iindex 0000000..1c7d76a
---- /dev/null
++++ b/esm/consent.mjs
@@@ -0,0 +1,8 @@
++// Generated by scripts/esm.js from src/consent.js. Do not edit.
++import cjs from '../src/consent.js';
++
++export const {
++ createConsent, PURPOSES, KINDS, FEATURES, MAX_SUBJECTS, DEFAULT_TTL_MS,
++ DEFAULT_TIMEOUT_MS,
++} = cjs;
++export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex b21c69c..c0a5f8e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.77.0",
++ "version": "0.78.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.77.0",
++ "version": "0.78.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 0cad83b..364b3ac 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.77.0",
++ "version": "0.78.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",
@@@ -387,6 +387,12 @@
"import": "./esm/redact.mjs",
"default": "./src/redact.js"
},
++ "./consent": {
++ "types": "./types/consent.d.ts",
++ "browser": null,
++ "import": "./esm/consent.mjs",
++ "default": "./src/consent.js"
++ },
"./vision": {
"types": "./types/vision.d.ts",
"import": "./esm/vision.mjs",
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex de8dd01..e2c10d9 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -72,6 +72,7 @@ const ENTRIES = {
'explicit.mjs': '../src/explicit.js',
'no-looks.mjs': '../src/no-looks.js',
'redact.mjs': '../src/redact.js',
++ 'consent.mjs': '../src/consent.js',
'vision.mjs': '../src/vision.js',
'front-page.mjs': '../src/front-page.js',
'food.mjs': '../src/food.js',
ddiff --git a/src/consent.js b/src/consent.js
nnew file mode 100644
iindex 0000000..085fb2a
---- /dev/null
++++ b/src/consent.js
@@@ -0,0 +1,247 @@
++'use strict';
++/**
++ * openvibe-sdk/consent — may a purpose use these people's content? (L24, 2026-10-31; Contracts 0.256.0
++ * network.consent@1.)
++ *
++ * Nothing a person makes is off-limits to AI or data use by design: each use is OFF until that person turns it on in
++ * OpenVibe.Network. A service asks this check before every use of a person's content or likeness, and never assumes.
++ *
++ * const { createConsent } = require('openvibe-sdk/consent');
++ * const tokens = createServiceTokenClient({ clientId: 'ai', clientSecret, audience: 'openvibe.network' });
++ * const consent = createConsent({ networkUrl, token: () => tokens.getToken() });
++ *
++ * if (await consent.allowed(author, 'ai.train', { kind: 'vods' })) build(author);
++ * const authors = await consent.filter(authors, 'ai.train', { kind: 'chat' });
++ * events.on('network.consent.changed', consent.onChange); // a revocation applies on the next check
++ *
++ * It FAILS CLOSED: a network error, a timeout (2 s by default), a non-200 answer, a malformed answer or a token that
++ * cannot be minted means FALSE for everyone asked. A failure is never cached as a yes; the next call asks again. It
++ * logs at most once a minute. An answer is cached for ttlMs (60 s by default), per subject, purpose, kind, client and
++ * feature; network.consent.changed drops that subject's answers for the purpose at once. A cached "no" is dropped the
++ * same way, so a person who turns a purpose on does not wait for the TTL.
++ *
++ * filter() asks in batches of 500 (the check's limit), in input order. mock() is for tests: it answers from a map and
++ * never touches the network. `fetch` and `log` may be injected; `now` is for tests.
++ */
++const PURPOSES = Object.freeze(['ai.train', 'data.public', 'face.recognize', 'app.data']);
++const KINDS = Object.freeze(['streams', 'vods', 'clips', 'voice', 'chat', 'posts', 'pictures', 'code']);
++const FEATURES = Object.freeze(['verify', 'find', 'cast', 'library']);
++const MAX_SUBJECTS = 500;
++const DEFAULT_TTL_MS = 60_000;
++const DEFAULT_TIMEOUT_MS = 2_000;
++const LOG_EVERY_MS = 60_000;
++
++const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
++
++/**
++ * The consent client.
++ * networkUrl OpenVibe.Network's base URL (no trailing slash needed)
++ * token () => a service token, or a Promise of one (it needs network.consent.read)
++ * ttlMs how long an answer is cached (default 60 s; 0 disables the cache)
++ * fetch the fetch implementation (default globalThis.fetch)
++ * log { warn } used at most once a minute on failures (default console; null silences)
++ * timeoutMs the 2 s timeout of one check (tests may shorten it)
++ * now the clock in ms (tests only)
++ */
++function createConsent({ networkUrl, token, ttlMs = DEFAULT_TTL_MS, fetch: fetchImpl = globalThis.fetch, log = console, timeoutMs = DEFAULT_TIMEOUT_MS, now = () => Date.now() } = {}) {
++ if (!networkUrl || typeof networkUrl !== 'string') throw new TypeError('createConsent: networkUrl is required');
++ if (typeof token !== 'function') throw new TypeError('createConsent: token must be a function returning a service token');
++ if (typeof fetchImpl !== 'function') throw new TypeError('createConsent: a fetch implementation is required');
++ if (!Number.isFinite(ttlMs) || ttlMs < 0) throw new TypeError('createConsent: ttlMs must be a number of milliseconds');
++ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new TypeError('createConsent: timeoutMs must be a positive number');
++ const base = String(networkUrl).replace(/\/+$/, '');
++ const cache = new Map(); // key -> { subject, purpose, allowed, expires }
++ let generation = 0; // bumped by onChange/mock/clear: a response started before is never cached
++ let lastLog = -Infinity;
++ let mocked = null; // Map<subject, Set<purpose>> while mock() is on
++
++ const logOnce = (message) => {
++ const at = now();
++ if (at - lastLog < LOG_EVERY_MS) return;
++ lastLog = at;
++ if (log && typeof log.warn === 'function') log.warn(`[consent] ${message}; treating every subject as not allowed`);
++ };
++
++ const optKey = (opts) => {
++ const o = isObject(opts) ? opts : {};
++ return [o.kind || '', o.client || '', o.feature || ''].join('\u0000');
++ };
++ const keyOf = (subject, purpose, opts) => [subject, purpose, optKey(opts)].join('\u0001');
++
++ function fresh(key) {
++ const entry = cache.get(key);
++ if (!entry) return undefined;
++ if (entry.expires <= now()) { cache.delete(key); return undefined; }
++ return entry.allowed;
++ }
++
++ function remember(key, subject, purpose, allowed, startedAtGeneration) {
++ if (startedAtGeneration !== generation || ttlMs <= 0) return;
++ cache.set(key, { subject, purpose, allowed, expires: now() + ttlMs });
++ // Bounded like the other SDK caches: a dataset build over many people must not grow forever.
++ if (cache.size > 10_000) {
++ const t = now();
++ for (const [k, e] of cache) if (e.expires <= t) cache.delete(k);
++ while (cache.size > 10_000) cache.delete(cache.keys().next().value);
++ }
++ }
++
++ /** One POST /internal/consent/check for at most 500 people. → { subject: boolean } or null. */
++ async function askBatch(purpose, opts, subjects) {
++ const body = { purpose, subjects };
++ if (opts && opts.kind) body.kind = String(opts.kind);
++ if (opts && opts.client) body.client = String(opts.client);
++ if (opts && opts.feature) body.feature = String(opts.feature);
++ const controller = new AbortController();
++ let timer = null;
++ // The deadline covers the token and the answer, not just the socket: a token endpoint that never answers is
++ // as closed as a Network that does not. A fetch that ignores the signal is raced, so it cannot hang the call.
++ const deadline = new Promise((_, reject) => {
++ timer = setTimeout(() => {
++ controller.abort();
++ reject(Object.assign(new Error('consent check timed out'), { name: 'AbortError' }));
++ }, timeoutMs);
++ });
++ const work = (async () => {
++ const bearer = await token();
++ if (!bearer) throw new Error('no service token');
++ const res = await fetchImpl(`${base}/internal/consent/check`, {
++ method: 'POST',
++ headers: { 'content-type': 'application/json', accept: 'application/json', authorization: `Bearer ${bearer}` },
++ body: JSON.stringify(body),
++ signal: controller.signal,
++ });
++ if (!res || res.status !== 200) return { answered: false, status: res ? res.status : 0 };
++ const data = await res.json().catch(() => null);
++ return { answered: true, data };
++ })();
++ work.catch(() => {}); // the race below sees it; this only prevents an unhandled rejection if the deadline won
++ try {
++ const out = await Promise.race([work, deadline]);
++ if (!out.answered) {
++ logOnce(`the check answered ${out.status || 'nothing'}`);
++ return null;
++ }
++ const data = out.data;
++ if (!isObject(data) || data.purpose !== purpose || !isObject(data.allowed)) {
++ logOnce('the check answered something that is not network.consent-check-result@1');
++ return null;
++ }
++ const allowed = {};
++ for (const subject of subjects) {
++ const value = data.allowed[subject];
++ if (value !== undefined && typeof value !== 'boolean') {
++ logOnce('the check answered a non-boolean for a subject');
++ return null;
++ }
++ allowed[subject] = value === true;
++ }
++ return allowed;
++ } catch (err) {
++ const why = err && (err.name === 'AbortError' || err.code === 'ABORT_ERR') ? `timed out after ${timeoutMs} ms` : `failed (${err && err.message ? err.message : err})`;
++ logOnce(why);
++ return null;
++ } finally {
++ clearTimeout(timer);
++ }
++ }
++
++ /**
++ * May `purpose` use each of these people? → { subject: boolean }. Only the cached answers and the batches that
++ * answered at all appear; a failed batch is false for its people and is not cached.
++ */
++ async function answers(subjects, purpose, opts) {
++ const list = [...new Set((Array.isArray(subjects) ? subjects : [subjects]).filter((s) => typeof s === 'string' && s))];
++ const out = {};
++ if (!PURPOSES.includes(purpose)) {
++ logOnce(`unknown purpose '${String(purpose).slice(0, 40)}'`);
++ for (const subject of list) out[subject] = false;
++ return out;
++ }
++ if (mocked) {
++ for (const subject of list) out[subject] = !!mocked.get(subject) && mocked.get(subject).has(purpose);
++ return out;
++ }
++ const misses = [];
++ for (const subject of list) {
++ const hit = fresh(keyOf(subject, purpose, opts));
++ if (hit === undefined) misses.push(subject);
++ else out[subject] = hit;
++ }
++ for (let i = 0; i < misses.length; i += MAX_SUBJECTS) {
++ const batch = misses.slice(i, i + MAX_SUBJECTS);
++ const started = generation;
++ const answered = await askBatch(purpose, opts, batch);
++ for (const subject of batch) {
++ const allowed = answered ? answered[subject] === true : false;
++ out[subject] = allowed;
++ if (answered) remember(keyOf(subject, purpose, opts), subject, purpose, allowed, started);
++ }
++ }
++ return out;
++ }
++
++ /** True only for an active choice that covers the purpose, kind, client and feature. False on every doubt. */
++ async function allowed(subject, purpose, opts = {}) {
++ if (typeof subject !== 'string' || !subject) return false;
++ const a = await answers([subject], purpose, opts);
++ return a[subject] === true;
++ }
++
++ /** The allowed subset of `subjects`, in input order, without duplicates; asked in batches of 500. */
++ async function filter(subjects, purpose, opts = {}) {
++ const list = [...new Set((Array.isArray(subjects) ? subjects : []).filter((s) => typeof s === 'string' && s))];
++ if (!list.length) return [];
++ const a = await answers(list, purpose, opts);
++ return list.filter((subject) => a[subject] === true);
++ }
++
++ /**
++ * Drop the answers for one subject and purpose (all kinds, clients and features), so the next check asks again.
++ * Accepts the network.consent.changed envelope, its payload, or a bare { subject, purpose }. Without both fields
++ * it clears everything (never worse than a full re-check). Returns how many entries were dropped.
++ */
++ function onChange(event) {
++ generation++;
++ const p = event && event.payload && typeof event.payload === 'object' ? event.payload : (event || {});
++ const subject = p.subject ? String(p.subject) : null;
++ const purpose = p.purpose ? String(p.purpose) : null;
++ let dropped = 0;
++ if (!subject || !purpose) {
++ dropped = cache.size;
++ cache.clear();
++ return dropped;
++ }
++ for (const [key, entry] of cache) {
++ if (entry.subject === subject && entry.purpose === purpose) { cache.delete(key); dropped++; }
++ }
++ return dropped;
++ }
++
++ /** Answer from a map ({ usr_x: ['ai.train'] }) without the network; mock(null) returns to the live check. */
++ function mock(map) {
++ generation++;
++ cache.clear();
++ if (map === null || map === undefined) { mocked = null; return api; }
++ if (!isObject(map)) throw new TypeError('consent.mock: pass an object of subject → purposes, or null');
++ mocked = new Map();
++ for (const [subject, purposes] of Object.entries(map)) {
++ if (!Array.isArray(purposes)) throw new TypeError(`consent.mock: ${subject} must map to an array of purposes`);
++ mocked.set(subject, new Set(purposes.map(String)));
++ }
++ return api;
++ }
++
++ /** Forget every cached answer. */
++ function clear() {
++ generation++;
++ cache.clear();
++ }
++
++ const api = { allowed, filter, onChange, mock, clear };
++ return api;
++}
++
++module.exports = {
++ createConsent, PURPOSES, KINDS, FEATURES, MAX_SUBJECTS, DEFAULT_TTL_MS, DEFAULT_TIMEOUT_MS,
++};
ddiff --git a/src/core/index.js b/src/core/index.js
iindex b8394b7..6c11a4f 100644
---- a/src/core/index.js
++++ b/src/core/index.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.77.0';
++const SDK_VERSION = '0.78.0';
module.exports = {
createClient,
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex c0dbea2..0661f20 100644
---- a/test/browser-entries.test.js
++++ b/test/browser-entries.test.js
@@@ -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', './actor', './blog', './cache', './commerce', './cost', './coupons', './db', './deals', './delivery', './download', './events', './food', './govern', './host', './identity', './inventory', './limits', './live', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './reviews', './run', './runtime', './service', './space', './sso', './storage', './telemetry', './testing', './tips', './trade', './usage', './valkey', './video', './vip', './wiki', './work', './zone']);
++ assert.deepEqual(blocked.sort(), ['./account-data', './actor', './blog', './cache', './commerce', './consent', './cost', './coupons', './db', './deals', './delivery', './download', './events', './food', './govern', './host', './identity', './inventory', './limits', './live', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './resources', './reviews', './run', './runtime', './service', './space', './sso', './storage', './telemetry', './testing', './tips', './trade', './usage', './valkey', './video', './vip', './wiki', './work', './zone']);
const rel = [...scanned].map((f) => path.relative(ROOT, f).split(path.sep).join('/')); // the same names on Windows
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`);
ddiff --git a/test/consent.test.js b/test/consent.test.js
nnew file mode 100644
iindex 0000000..65a5594
---- /dev/null
++++ b/test/consent.test.js
@@@ -0,0 +1,240 @@
++'use strict';
++/**
++ * openvibe-sdk/consent (L24, 2026-10-31): the client every service uses before it uses a person's content or
++ * likeness for AI or data. What this proves:
++ * - an answer is cached for ttlMs and asked again after it, per subject, purpose, kind, client and feature;
++ * - network.consent.changed drops that subject's answers for the purpose at once (and only those);
++ * - every failure is a false answer — network error, timeout, non-200, malformed, a token that cannot be minted —
++ * and a failure is never cached as a yes; it logs at most once a minute;
++ * - filter() batches by 500, keeps input order and drops duplicates;
++ * - mock() answers from a map without the network.
++ */
++const assert = require('node:assert/strict');
++const { stubServer, send, run, sleep } = require('./helpers');
++const { createConsent, DEFAULT_TTL_MS, DEFAULT_TIMEOUT_MS, MAX_SUBJECTS } = require('../src/consent');
++
++const ok = (data) => ({
++ status: 200,
++ body: { purpose: data.purpose, allowed: Object.fromEntries(data.subjects.map((s) => [s, true])), ...(data.kind ? { kind: data.kind } : {}), checked_at: new Date().toISOString() },
++});
++
++/** One stub Network whose answers the test steers. */
++async function network() {
++ const state = { answer: ok, requests: [], token: 'svc-token' };
++ const stub = await stubServer(async (req, res, body) => {
++ const data = JSON.parse(body.toString() || '{}');
++ state.requests.push({ method: req.method, url: req.url, headers: req.headers, body: data });
++ const a = state.answer(data, state.requests.length);
++ if (!a || a.hang) return await new Promise(() => {});
++ if (a.delayMs) await sleep(a.delayMs);
++ if (a.badJson) { res.writeHead(200, { 'content-type': 'application/json' }); res.end('not json'); return; }
++ send(res, a.status || 200, a.body === undefined ? '' : a.body);
++ });
++ return { stub, state };
++}
++
++run([
++ ['the defaults are the agreed 60 s cache and 2 s timeout', async () => {
++ assert.equal(DEFAULT_TTL_MS, 60_000);
++ assert.equal(DEFAULT_TIMEOUT_MS, 2_000);
++ assert.equal(MAX_SUBJECTS, 500);
++ }],
++
++ ['an answer is cached for ttlMs, then asked again', async () => {
++ const { stub, state } = await network();
++ let clock = 1_000_000;
++ const consent = createConsent({ networkUrl: stub.url, token: () => state.token, now: () => clock, fetch: globalThis.fetch });
++ try {
++ assert.equal(await consent.allowed('usr_a', 'ai.train', { kind: 'vods' }), true);
++ assert.equal(await consent.allowed('usr_a', 'ai.train', { kind: 'vods' }), true);
++ assert.equal(state.requests.length, 1, 'the second answer comes from the cache');
++ // A different kind is a different question.
++ await consent.allowed('usr_a', 'ai.train', { kind: 'clips' });
++ assert.equal(state.requests.length, 2);
++ assert.equal(state.requests[0].body.kind, 'vods');
++ // Just before the TTL.
++ clock += 59_999;
++ await consent.allowed('usr_a', 'ai.train', { kind: 'vods' });
++ assert.equal(state.requests.length, 2, 'still cached');
++ // At the TTL it is asked again.
++ clock += 1;
++ await consent.allowed('usr_a', 'ai.train', { kind: 'vods' });
++ assert.equal(state.requests.length, 3, 'the TTL expired');
++ } finally { await stub.close(); }
++ }],
++
++ ['the request is the contract check, with the service token', async () => {
++ const { stub, state } = await network();
++ const consent = createConsent({ networkUrl: stub.url + '/', token: async () => state.token });
++ try {
++ await consent.allowed('usr_a', 'face.recognize', { feature: 'verify', client: 'app-one' });
++ const r = state.requests[0];
++ assert.equal(r.method, 'POST');
++ assert.equal(r.url, '/internal/consent/check');
++ assert.equal(r.headers.authorization, 'Bearer svc-token');
++ assert.deepEqual(r.body, { purpose: 'face.recognize', subjects: ['usr_a'], client: 'app-one', feature: 'verify' });
++ } finally { await stub.close(); }
++ }],
++
++ ['network.consent.changed drops that subject and purpose, and only those', async () => {
++ const { stub, state } = await network();
++ const consent = createConsent({ networkUrl: stub.url, token: () => state.token });
++ try {
++ await consent.allowed('usr_a', 'ai.train');
++ await consent.allowed('usr_a', 'data.public');
++ await consent.allowed('usr_b', 'ai.train');
++ assert.equal(state.requests.length, 3);
++ const dropped = consent.onChange({ payload: { subject: 'usr_a', purpose: 'ai.train' } });
++ assert.equal(dropped, 1);
++ await consent.allowed('usr_a', 'ai.train');
++ assert.equal(state.requests.length, 4, 'the revoked purpose is asked again');
++ await consent.allowed('usr_a', 'data.public');
++ await consent.allowed('usr_b', 'ai.train');
++ assert.equal(state.requests.length, 4, 'the other purpose and the other subject stay cached');
++ // The bare payload works too, and no fields clears everything.
++ consent.onChange({ subject: 'usr_b', purpose: 'ai.train' });
++ await consent.allowed('usr_b', 'ai.train');
++ assert.equal(state.requests.length, 5);
++ assert.ok(consent.onChange(null) >= 2);
++ await consent.allowed('usr_a', 'data.public');
++ assert.equal(state.requests.length, 6);
++ } finally { await stub.close(); }
++ }],
++
++ ['an answer started before a change is never cached (the revocation wins)', async () => {
++ const { stub, state } = await network();
++ state.answer = (data) => ({ delayMs: 40, body: ok(data).body });
++ const consent = createConsent({ networkUrl: stub.url, token: () => state.token });
++ try {
++ const pending = consent.allowed('usr_a', 'ai.train');
++ await sleep(10);
++ consent.onChange({ payload: { subject: 'usr_a', purpose: 'ai.train' } });
++ assert.equal(await pending, true, 'the answer itself is what the Network said');
++ state.answer = ok;
++ await consent.allowed('usr_a', 'ai.train'); // must ask again: the in-flight answer was not stored
++ assert.equal(state.requests.length, 2);
++ } finally { await stub.close(); }
++ }],
++
++ ['a network error is false, is not cached, and does not throw', async () => {
++ const dead = await network();
++ const url = dead.stub.url;
++ await dead.stub.close();
++ const consent = createConsent({ networkUrl: url, token: () => 'svc-token', timeoutMs: 500, log: null });
++ assert.equal(await consent.allowed('usr_a', 'ai.train'), false);
++ assert.equal(await consent.allowed('usr_a', 'ai.train'), false, 'still false, never an error');
++ }],
++
++ ['a timeout (2 s by default, shorter here) is false and not cached', async () => {
++ const { stub, state } = await network();
++ state.answer = () => ({ hang: true });
++ const consent = createConsent({ networkUrl: stub.url, token: () => state.token, timeoutMs: 30, log: null });
++ try {
++ const t0 = Date.now();
++ assert.equal(await consent.allowed('usr_a', 'ai.train'), false);
++ assert.ok(Date.now() - t0 < 1500, 'the timeout ended the call');
++ state.answer = ok;
++ assert.equal(await consent.allowed('usr_a', 'ai.train'), true, 'the failure was not cached');
++ assert.equal(state.requests.length, 2);
++ } finally { await stub.close(); }
++ }],
++
++ ['a token endpoint that never answers is a timeout too', async () => {
++ const consent = createConsent({ networkUrl: 'http://127.0.0.1:9', token: () => new Promise(() => {}), timeoutMs: 30, log: null });
++ const t0 = Date.now();
++ assert.equal(await consent.allowed('usr_a', 'ai.train'), false);
++ assert.ok(Date.now() - t0 < 1500, 'the deadline covers the token, not only the socket');
++ }],
++
++ ['a non-200 is false and not cached', async () => {
++ const { stub, state } = await network();
++ state.answer = () => ({ status: 403, body: { code: 'capability.denied' } });
++ const consent = createConsent({ networkUrl: stub.url, token: () => state.token, log: null });
++ try {
++ assert.equal(await consent.allowed('usr_a', 'ai.train'), false);
++ state.answer = ok;
++ assert.equal(await consent.allowed('usr_a', 'ai.train'), true);
++ assert.equal(state.requests.length, 2);
++ } finally { await stub.close(); }
++ }],
++
++ ['a malformed answer is false and not cached', async () => {
++ const { stub, state } = await network();
++ const consent = createConsent({ networkUrl: stub.url, token: () => state.token, log: null });
++ try {
++ state.answer = () => ({ badJson: true });
++ assert.equal(await consent.allowed('usr_a', 'ai.train'), false);
++ state.answer = () => ({ body: { purpose: 'ai.train', allowed: 'everyone' } });
++ assert.equal(await consent.allowed('usr_a', 'ai.train'), false);
++ state.answer = () => ({ body: { purpose: 'data.public', allowed: { usr_a: true } } });
++ assert.equal(await consent.allowed('usr_a', 'ai.train'), false, 'the purpose must be the one asked');
++ state.answer = () => ({ body: { purpose: 'ai.train', allowed: { usr_a: 'yes' } } });
++ assert.equal(await consent.allowed('usr_a', 'ai.train'), false, 'a non-boolean is not a yes');
++ state.answer = ok;
++ assert.equal(await consent.allowed('usr_a', 'ai.train'), true);
++ assert.equal(state.requests.length, 5);
++ } finally { await stub.close(); }
++ }],
++
++ ['a token that cannot be minted is false and not cached', async () => {
++ let broken = true;
++ const { stub, state } = await network();
++ const consent = createConsent({ networkUrl: stub.url, token: () => { if (broken) throw new Error('no token'); return state.token; }, log: null });
++ try {
++ assert.equal(await consent.allowed('usr_a', 'ai.train'), false);
++ broken = false;
++ assert.equal(await consent.allowed('usr_a', 'ai.train'), true);
++ assert.equal(state.requests.length, 1);
++ } finally { await stub.close(); }
++ }],
++
++ ['it logs at most once a minute', async () => {
++ const { stub, state } = await network();
++ let clock = 10_000_000;
++ const lines = [];
++ const consent = createConsent({ networkUrl: stub.url, token: () => state.token, now: () => clock, log: { warn: (m) => lines.push(m) } });
++ try {
++ state.answer = () => ({ status: 500, body: {} });
++ await consent.allowed('usr_a', 'ai.train', { kind: 'vods' });
++ await consent.allowed('usr_a', 'ai.train', { kind: 'clips' });
++ await consent.allowed('usr_b', 'ai.train');
++ assert.equal(lines.length, 1, 'one warning for three failures in the minute');
++ clock += 60_000;
++ await consent.allowed('usr_c', 'ai.train');
++ assert.equal(lines.length, 2, 'and one more after the minute');
++ } finally { await stub.close(); }
++ }],
++
++ ['filter batches by 500, keeps input order and drops duplicates', async () => {
++ const { stub, state } = await network();
++ state.answer = (data) => ({ body: { purpose: data.purpose, allowed: Object.fromEntries(data.subjects.map((s) => [s, Number(s.slice(-3)) % 2 === 0])), checked_at: new Date().toISOString() } });
++ const consent = createConsent({ networkUrl: stub.url, token: () => state.token });
++ try {
++ const subjects = Array.from({ length: 1200 }, (_, i) => `usr_${String(i).padStart(3, '0')}`);
++ subjects.splice(3, 0, subjects[0]); // a duplicate
++ const allowed = await consent.filter(subjects, 'ai.train', { kind: 'chat' });
++ assert.equal(state.requests.length, 3, '1200 people = 3 checks');
++ assert.deepEqual(state.requests.map((r) => r.body.subjects.length), [500, 500, 200]);
++ assert.deepEqual(allowed, [...new Set(subjects)].filter((s) => Number(s.slice(-3)) % 2 === 0), 'the allowed subset, in input order');
++ // The answers are cached like allowed().
++ await consent.filter(subjects.slice(0, 500), 'ai.train', { kind: 'chat' });
++ assert.equal(state.requests.length, 3);
++ } finally { await stub.close(); }
++ }],
++
++ ['mock answers from a map without the network; mock(null) goes live', async () => {
++ const { stub, state } = await network();
++ const consent = createConsent({ networkUrl: stub.url, token: () => state.token, log: null });
++ try {
++ consent.mock({ usr_a: ['ai.train', 'data.public'], usr_b: ['face.recognize'] });
++ assert.equal(await consent.allowed('usr_a', 'ai.train'), true);
++ assert.equal(await consent.allowed('usr_a', 'face.recognize'), false);
++ assert.deepEqual(await consent.filter(['usr_a', 'usr_b', 'usr_c'], 'face.recognize'), ['usr_b']);
++ assert.equal(state.requests.length, 0, 'the mock never calls the Network');
++ consent.mock(null);
++ assert.equal(await consent.allowed('usr_a', 'ai.train'), true);
++ assert.equal(state.requests.length, 1, 'a miss goes live again');
++ assert.throws(() => consent.mock({ usr_a: 'ai.train' }), /array of purposes/);
++ } finally { await stub.close(); }
++ }],
++]);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex cf2451a..3031183 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -68,7 +68,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, 72); // 0.77.0 adds openvibe-sdk/explicit
++ assert.equal(subpaths.length, 73); // 0.78.0 adds openvibe-sdk/consent
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 });
ddiff --git a/types/consent.d.ts b/types/consent.d.ts
nnew file mode 100644
iindex 0000000..88297ac
---- /dev/null
++++ b/types/consent.d.ts
@@@ -0,0 +1,49 @@
++/** openvibe-sdk/consent: may a purpose use these people's content? Server-side; it sends a service token. */
++export type ConsentPurpose = 'ai.train' | 'data.public' | 'face.recognize' | 'app.data';
++export type ConsentKind = 'streams' | 'vods' | 'clips' | 'voice' | 'chat' | 'posts' | 'pictures' | 'code';
++export type ConsentFeature = 'verify' | 'find' | 'cast' | 'library';
++
++export interface ConsentQueryOptions {
++ kind?: ConsentKind;
++ client?: string;
++ feature?: ConsentFeature;
++}
++
++/** The network.consent.changed envelope or payload; either is accepted by onChange. */
++export interface ConsentChangeEvent {
++ payload?: { subject?: string; purpose?: ConsentPurpose | string; [k: string]: unknown };
++ subject?: string;
++ purpose?: ConsentPurpose | string;
++ [k: string]: unknown;
++}
++
++export interface ConsentClientOptions {
++ networkUrl: string;
++ token: () => string | Promise<string>;
++ ttlMs?: number;
++ fetch?: typeof fetch;
++ log?: { warn(...a: unknown[]): void } | null;
++ timeoutMs?: number;
++ now?: () => number;
++}
++
++export interface ConsentClient {
++ /** True only when the person turned this purpose on; false on any doubt (network, timeout, malformed, no token). */
++ allowed(subject: string, purpose: ConsentPurpose, opts?: ConsentQueryOptions): Promise<boolean>;
++ /** The allowed subset of `subjects`, in input order, without duplicates; asked in batches of 500. */
++ filter(subjects: string[], purpose: ConsentPurpose, opts?: ConsentQueryOptions): Promise<string[]>;
++ /** Drop the cache for the change's subject and purpose (wire it to network.consent.changed); returns how many. */
++ onChange(event?: ConsentChangeEvent | null): number;
++ /** Answer from a map of subject → purposes, without the network; mock(null) returns to the live check. */
++ mock(map?: Record<string, string[]> | null): ConsentClient;
++ /** Forget every cached answer. */
++ clear(): void;
++}
++
++export function createConsent(options: ConsentClientOptions): ConsentClient;
++export const PURPOSES: readonly ConsentPurpose[];
++export const KINDS: readonly ConsentKind[];
++export const FEATURES: readonly ConsentFeature[];
++export const MAX_SUBJECTS: number;
++export const DEFAULT_TTL_MS: number;
++export const DEFAULT_TIMEOUT_MS: number;