openvibers/openvibe.sdk Commits
0.7.0: openvibe-sdk/vip, the VIP consumer seam (createVipClient, createVipCache) published once instead of the copies of openvibe-vip/client in Chat, Community and Blog; server-only; fail-closed, one token retry, cache convergence on vip.membership.changed and Billing's entitlement events. Types, ESM entry, README row, CHANGELOG; test/vip.test.js
Changes
@@@ -3,6 +3,16 @@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.7.0 (2026-09-24)++++`openvibe-sdk/vip`: the consumer seam for products that honour OpenVibe.VIP memberships++(`createVipClient`, `createVipCache`), published once here instead of the verbatim copies of VIP's++`openvibe-vip/client` that Chat, Community and Blog carried. Server-only (`"browser": null`); the++same API and fail-closed behaviour: VIP unreachable, a timeout, a refused token or a malformed answer++is a denial, a refused token is invalidated and retried once, and the cache drops a member's answers++for a creator on `vip.membership.changed` (and Billing's entitlement, cancellation and reversal++events) instead of waiting out its TTL. Additive.++## 0.6.0 (2026-09-23)The OpenVibe.Tools platform API (ADR-027, openvibe-contracts v0.33.0) as `openvibe-sdk/tools`, and@@@ -167,6 +167,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client| `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/tools` | both | `createToolsClient(client, {baseUrl?, anonymousReads?})`: `list({family, q, execution, api, status})`, `get(id)`, `schema(id)`, `run(id, input, {files, waitMs, idempotencyKey, signal, timeoutMs})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()` || `openvibe-sdk/projects` | both (user token) | `createProjectsClient(client)`: `catalog`, `list`, `create`, `get`, `update`, `archive`, `setAllowance`, `setEnvironmentPolicy`, `members.*`, `apps.*`, `credentials.list/rotate/revoke`, `grants.list/request/approve/deny/revoke`, `quotas.*`, `audit`, `iterateAudit` |++| `openvibe-sdk/vip` | server | `createVipClient({baseUrl?, tokenClient \| getToken, fetch?, timeoutMs?})`: `evaluate({subject, resource, owner, fallback, mode})`, `checkEntitlement({subject, creator, product, mode})`, `isMember(subject, creator)`; every failure is a denial. `createVipCache({vip, ttlMs, denyTtlMs, unavailableTtlMs})`: `entitlement`, `peekEntitlement`, `evaluate`, `invalidate`, `handleEvent` (`vip.membership.changed` and Billing's entitlement events drop a member's answers at once), `clear`, `bounds` || `openvibe-sdk/testing` | Node | `createMockPlatform()`: fake Network (incl. developer apps and projects), Events, Media, Tools jobs and the Tools platform API on an in-process `fetch` || `openvibe-sdk/browser/openvibe-sdk.mjs` | browser | one self-contained ES module: core + auth (browser), registry, modules, realtime, media, community, jobs, tools, projects |@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.6.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.7.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, jobs, tools, projects). No server-only code: no service tokens, JWT verification, events, identity./* eslint-disable */@@@ -37,7 +37,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.6.0';++const SDK_VERSION = '0.7.0';module.exports = {createClient,@@@ -6,6 +6,6 @@ export const {startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,registry, identity, modules, events, realtime, media,-- community, jobs, tools, projects,++ community, jobs, tools, projects, vip,} = cjs;export default cjs;nnew file mode 100644@@@ -0,0 +1,7 @@++// Generated by scripts/esm.js from src/vip.js. Do not edit.++import cjs from '../src/vip.js';++++export const {++ createVipClient, createVipCache,++} = cjs;++export default cjs;@@@ -19,4 +19,5 @@ module.exports = {jobs: require('./src/jobs'),tools: require('./src/tools'),projects: require('./src/projects'),++ vip: require('./src/vip'),};@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.6.0",++ "version": "0.7.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.6.0",++ "version": "0.7.0","license": "MIT","devDependencies": {"better-sqlite3": "^11.10.0",@@@ -1,7 +1,7 @@{"name": "openvibe-sdk",-- "version": "0.6.0",-- "description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Tools (registry, run API and jobs) and developer projects, with deadlines, safe retries, problem errors and trace propagation.",++ "version": "0.7.0",++ "description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Tools (registry, run API and jobs), VIP memberships and developer projects, with deadlines, safe retries, problem errors and trace propagation.","main": "index.js","types": "types/index.d.ts","browser": "browser.js",@@@ -80,6 +80,12 @@"import": "./esm/projects.mjs","default": "./src/projects.js"},++ "./vip": {++ "types": "./types/vip.d.ts",++ "browser": null,++ "import": "./esm/vip.mjs",++ "default": "./src/vip.js"++ },"./testing": {"types": "./types/testing.d.ts","browser": null,@@@ -26,6 +26,7 @@ const ENTRIES = {'jobs.mjs': '../src/jobs.js','tools.mjs': '../src/tools.js','projects.mjs': '../src/projects.js',++ 'vip.mjs': '../src/vip.js','testing.mjs': '../src/testing/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.6.0';++const SDK_VERSION = '0.7.0';module.exports = {createClient,nnew file mode 100644@@@ -0,0 +1,236 @@++'use strict';++++/**++ * openvibe-sdk/vip — the consumer seam for products that honour OpenVibe.VIP memberships (Live, Chat,++ * Community, Blog, Wiki, …): VIP's openvibe-vip/client, published once here instead of being copied++ * into every product. Server-side (it sends a service token); no dependencies beyond fetch.++ *++ * const { createVipClient, createVipCache } = require('openvibe-sdk/vip');++ * const tokens = createServiceTokenClient({ tokenUrl, clientId, clientSecret, audience: 'openvibe.vip' });++ * const vip = createVipClient({ baseUrl: 'http://127.0.0.1:4620', tokenClient: tokens });++ *++ * const d = await vip.evaluate({ subject: viewerSubjectOrNull, resource: { service: 'blog', type: 'post', id: '42' }, owner: authorSubject,++ * fallback: { requirement: 'member', binding: 'blog:gated_post' } }); // the product's default gate++ * if (!d.allow) return renderTeaser(d.reason); // FAILS CLOSED++ *++ * const e = await vip.checkEntitlement({ subject: 'usr_…', creator: 'usr_…', product: 'chat' }); // e.active, e.product_perks++ * await vip.isMember('usr_…', 'usr_…'); // boolean, false on any doubt++ *++ * tokenClient is anything with authHeaders() (and optionally invalidate()): openvibe-sdk/auth's++ * createServiceTokenClient or openvibe-contracts serviceAuth.createTokenClient.++ *++ * Every failure — VIP unreachable, a timeout, a refused token, a malformed answer — is a denial++ * ({ allow: false, reason: 'vip_unavailable' } / { status: 'unknown', active: false }), never an++ * exception and never a "yes". Pass mode: 'authoritative' for sensitive content: VIP then asks++ * OpenVibe.Billing directly instead of its projection.++ *++ * Put createVipCache (below) in front of it: it holds the convergence bound every product shares.++ */++++function createVipClient({ baseUrl = 'http://127.0.0.1:4620', tokenClient = null, getToken = null, fetch: fetchImpl = globalThis.fetch, timeoutMs = 2000, log = null } = {}) {++ if (typeof fetchImpl !== 'function') throw new TypeError('createVipClient: a fetch implementation is required');++ const base = String(baseUrl).replace(/\/+$/, '');++++ async function headers() {++ if (tokenClient && typeof tokenClient.authHeaders === 'function') return tokenClient.authHeaders();++ if (typeof getToken === 'function') return { Authorization: `Bearer ${await getToken()}` };++ throw new Error('createVipClient: tokenClient or getToken is required');++ }++++ async function post(path, body, retried = false) {++ const res = await fetchImpl(`${base}${path}`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await headers()) },++ body: JSON.stringify(body),++ signal: AbortSignal.timeout(timeoutMs),++ });++ if (res.status === 401 && !retried && tokenClient && typeof tokenClient.invalidate === 'function') { tokenClient.invalidate(); return post(path, body, true); }++ const data = await res.json().catch(() => null);++ if (!res.ok || !data || typeof data !== 'object') throw new Error(`VIP ${res.status}: ${(data && (data.code || data.detail)) || 'bad response'}`);++ return data;++ }++ const note = (what, err) => { if (log && log.warn) log.warn(`[vip-client] ${what} failed closed: ${err.message}`); };++++ /** May `subject` (a usr_ id, a SubjectRef, or null for a signed-out viewer) see `resource`? */++ async function evaluate({ subject = null, resource, owner = null, ruleId, mode, fallback } = {}) {++ try {++ const d = await post('/api/v1/policies/evaluate', { subject: subject || null, resource, owner, rule_id: ruleId, mode, fallback });++ return { ...d, allow: d.allow === true, reason: d.reason || (d.allow === true ? 'member' : 'denied') };++ } catch (err) {++ note('evaluate', err);++ return { allow: false, reason: 'vip_unavailable', error: err.message };++ }++ }++++ /** The entitlement of `subject` to `creator` (usr_ ids or SubjectRefs). */++ async function checkEntitlement({ subject, creator, mode, product } = {}) {++ try {++ const e = await post('/api/v1/entitlements/check', { subject, creator, mode, product });++ return { ...e, active: e.active === true && e.status === 'active' };++ } catch (err) {++ note('checkEntitlement', err);++ return { status: 'unknown', active: false, reason: 'vip_unavailable', error: err.message };++ }++ }++++ const isMember = async (subject, creator, opts = {}) => (await checkEntitlement({ subject, creator, mode: opts.mode })).active;++++ return { evaluate, checkEntitlement, isMember };++}++++/**++ * createVipCache({ vip, ttlMs, denyTtlMs, unavailableTtlMs, maxEntries, now }) — the product-side++ * cache every consumer puts in front of the client, with ONE convergence rule:++ *++ * A cached "yes" lives at most ttlMs (and never past the entitlement's expires_at or VIP's++ * valid_until). So once VIP stops granting — it has applied Billing's billing.entitlement.changed++ * and emitted vip.membership.changed — the product stops granting within ttlMs. handleEvent(envelope)++ * with either event (or billing.subscription.canceled / billing.transaction.reversed) drops that++ * member's entries for that creator at once, so a product that receives the events converges++ * immediately. A "no" lives denyTtlMs (a new member waits at most that long); a failure++ * (vip_unavailable / unknown) lives unavailableTtlMs, and a cached "yes" is never extended by a++ * failure: when VIP is down, entries expire into denials (fail closed).++ *++ * await cache.entitlement({ subject, creator, product }) → the checkEntitlement answer (cached)++ * cache.peekEntitlement({ subject, creator, product }) → the cached answer or undefined; never waits++ * (a miss starts a fetch in the background)++ * await cache.evaluate({ subject, resource, owner, fallback, mode }) → the evaluate answer (cached;++ * mode 'authoritative' is never cached)++ * cache.invalidate({ member, creator }), cache.handleEvent(envelope), cache.clear()++ */++function createVipCache({ vip, ttlMs = 30_000, denyTtlMs = 10_000, unavailableTtlMs = 2_000, maxEntries = 5000, now = () => Date.now() } = {}) {++ if (!vip || typeof vip.evaluate !== 'function' || typeof vip.checkEntitlement !== 'function') throw new TypeError('createVipCache: a VIP client is required');++ const entries = new Map(); // key → { value, expires, member, creator }++ const inflight = new Map(); // key → Promise++ let generation = 0; // bumped by clear(); an answer fetched before a clear is not stored++ const pairGen = new Map(); // `${member}|${creator}` → generation of its last invalidation++++ const idOf = (v) => (v && typeof v === 'object' ? (v.id != null ? String(v.id) : null) : (v != null && v !== '' ? String(v) : null));++ const pairKey = (member, creator) => `${member || ''}|${creator || ''}`;++++ function lifetime(value, allowed) {++ const t = now();++ if (value && (value.reason === 'vip_unavailable' || value.status === 'unknown' || value.reason === 'entitlement_unknown')) return unavailableTtlMs;++ if (!allowed) return denyTtlMs;++ let life = ttlMs;++ const ent = value.entitlement !== undefined ? value.entitlement : value;++ for (const at of [ent && ent.expires_at, ent && ent.valid_until]) {++ const ms = at ? Date.parse(at) : NaN;++ if (Number.isFinite(ms)) life = Math.min(life, ms - t);++ }++ if (ent && ent.stale) life = Math.min(life, unavailableTtlMs);++ return Math.max(0, life);++ }++++ function store(key, value, allowed, member, creator, startedGen, startedPair) {++ if (startedGen !== generation || (pairGen.get(pairKey(member, creator)) || 0) !== startedPair) return; // invalidated meanwhile++ const life = lifetime(value, allowed);++ if (life <= 0) { entries.delete(key); return; }++ if (entries.size >= maxEntries) {++ const t = now();++ for (const [k, e] of entries) if (e.expires <= t) entries.delete(k);++ while (entries.size >= maxEntries) entries.delete(entries.keys().next().value);++ }++ entries.set(key, { value, expires: now() + life, member, creator });++ }++++ function fresh(key) {++ const e = entries.get(key);++ if (!e) return undefined;++ if (e.expires <= now()) { entries.delete(key); return undefined; }++ return e.value;++ }++++ function load(key, member, creator, fetcher, allowedOf) {++ const hit = fresh(key);++ if (hit !== undefined) return Promise.resolve(hit);++ if (inflight.has(key)) return inflight.get(key);++ const g = generation;++ const pg = pairGen.get(pairKey(member, creator)) || 0;++ const p = (async () => {++ try {++ const value = await fetcher();++ store(key, value, allowedOf(value), member, creator, g, pg);++ return value;++ } finally { inflight.delete(key); }++ })();++ inflight.set(key, p);++ return p;++ }++++ function entitlement({ subject, creator, product = null, mode } = {}) {++ const member = idOf(subject);++ const owner = idOf(creator);++ if (!member || !owner) return Promise.resolve({ status: 'inactive', active: false, reason: 'not_signed_in' });++ if (mode === 'authoritative') return vip.checkEntitlement({ subject: member, creator: owner, mode, product: product || undefined });++ const key = `e|${member}|${owner}|${product || ''}`;++ return load(key, member, owner, () => vip.checkEntitlement({ subject: member, creator: owner, product: product || undefined }), (v) => v.active === true);++ }++++ function peekEntitlement(args = {}) {++ const member = idOf(args.subject);++ const owner = idOf(args.creator);++ if (!member || !owner) return undefined;++ const key = `e|${member}|${owner}|${args.product || ''}`;++ const hit = fresh(key);++ if (hit === undefined) entitlement(args).catch(() => {});++ return hit;++ }++++ function evaluate({ subject = null, resource, owner, fallback, mode } = {}) {++ const member = idOf(subject);++ const creator = idOf(owner);++ if (mode === 'authoritative' || !resource || typeof resource !== 'object') return vip.evaluate({ subject: member, resource, owner: creator, fallback, mode });++ if (!member) return Promise.resolve({ allow: false, reason: 'not_signed_in' });++ const fb = fallback == null ? '' : JSON.stringify(fallback);++ const key = `p|${member}|${creator || ''}|${resource.service}|${resource.type}|${resource.id}|${fb}`;++ return load(key, member, creator, () => vip.evaluate({ subject: member, resource, owner: creator, fallback }), (v) => v.allow === true);++ }++++ /** Drop what the cache knows about (member, creator); either may be omitted to drop more. */++ function invalidate({ member = null, creator = null } = {}) {++ const m = idOf(member);++ const c = idOf(creator);++ if (m && c) pairGen.set(pairKey(m, c), (pairGen.get(pairKey(m, c)) || 0) + 1);++ else generation++;++ let dropped = 0;++ for (const [k, e] of entries) {++ if ((!m || e.member === m) && (!c || e.creator === c)) { entries.delete(k); dropped++; }++ }++ return dropped;++ }++++ /**++ * An OpenVibe.Events envelope about a membership → invalidate that pair. Returns true when the++ * event was understood. vip.membership.changed { member, creator }; billing.entitlement.changed++ * { subject, streamer }; billing.subscription.canceled { subscription: { subscriber, streamer } };++ * billing.transaction.reversed { to_subject (member), from_subject (creator) }.++ */++ function handleEvent(envelope) {++ const e = envelope && envelope.event && envelope.event.event_type ? envelope.event : envelope;++ const p = (e && e.payload) || {};++ let member = null;++ let creator = null;++ switch (e && e.event_type) {++ case 'vip.membership.changed': member = idOf(p.member); creator = idOf(p.creator); break;++ case 'billing.entitlement.changed': member = idOf(p.subject); creator = idOf(p.streamer); break;++ case 'billing.subscription.canceled': member = idOf(p.subscription && p.subscription.subscriber); creator = idOf(p.subscription && p.subscription.streamer); break;++ case 'billing.transaction.reversed': member = idOf(p.to_subject); creator = idOf(p.from_subject); break;++ default: return false;++ }++ if (!member || !creator) return false;++ invalidate({ member, creator });++ return true;++ }++++ function clear() { generation++; entries.clear(); }++++ return {++ entitlement, peekEntitlement, evaluate, invalidate, handleEvent, clear,++ get size() { return entries.size; },++ /** The convergence bound, for docs and /ready: how long a cached "yes" can outlive VIP's "no". */++ bounds: Object.freeze({ grantMs: ttlMs, denyMs: denyTtlMs, unavailableMs: unavailableTtlMs }),++ };++}++++module.exports = { createVipClient, createVipCache };@@@ -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(), ['./events', './identity', './testing']);++ assert.deepEqual(blocked.sort(), ['./events', './identity', './testing', './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/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`);@@@ -22,7 +22,7 @@ run([const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8' });const info = JSON.parse(out)[0];const files = info.files.map((f) => f.path);-- for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'browser/openvibe-sdk.mjs']) {++ for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'browser/openvibe-sdk.mjs']) {assert.ok(files.includes(must), `tarball contains ${must}`);}assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');@@@ -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, 15);++ assert.equal(subpaths.length, 16);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,87 @@++'use strict';++// openvibe-sdk/vip: the VIP consumer seam, published once (it was copied into Chat, Community and Blog).++// Fails closed on every doubt, retries a refused token once, and the cache converges on membership events.++const assert = require('assert');++const { createVipClient, createVipCache } = require('../src/vip');++++const M = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const C = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';++++function stubVip() {++ const s = { calls: [], active: true, mode: 'ok', tokens: 0, invalidated: 0 };++ s.fetch = async (url, opts) => {++ const path = new URL(url).pathname;++ const body = JSON.parse(opts.body || '{}');++ s.calls.push({ path, body, auth: opts.headers.Authorization });++ if (s.mode === 'down') throw new Error('ECONNREFUSED');++ if (s.mode === 'refuse-once') { s.mode = 'ok'; return { ok: false, status: 401, json: async () => ({}) }; }++ if (s.mode === 'garbage') return { ok: true, status: 200, json: async () => ({ nonsense: true }) };++ if (path === '/api/v1/entitlements/check') return { ok: true, status: 200, json: async () => ({ status: s.active ? 'active' : 'inactive', active: s.active, product_perks: s.active ? ['badge'] : [] }) };++ if (path === '/api/v1/policies/evaluate') return { ok: true, status: 200, json: async () => ({ allow: s.active, reason: s.active ? 'member' : 'not_member' }) };++ return { ok: false, status: 404, json: async () => ({}) };++ };++ s.tokenClient = { authHeaders: async () => ({ Authorization: `Bearer t${++s.tokens}` }), invalidate: () => { s.invalidated++; } };++ return s;++}++++(async () => {++ let n = 0;++ const check = async (name, fn) => { await fn(); n++; };++++ await check('entitlement and evaluate answer through the service token', async () => {++ const s = stubVip();++ const vip = createVipClient({ baseUrl: 'http://vip.test/', tokenClient: s.tokenClient, fetch: s.fetch });++ const e = await vip.checkEntitlement({ subject: M, creator: C, product: 'chat' });++ assert.strictEqual(e.active, true);++ assert.deepStrictEqual(s.calls[0].body, { subject: M, creator: C, product: 'chat' });++ assert.strictEqual(s.calls[0].auth, 'Bearer t1');++ const d = await vip.evaluate({ subject: M, resource: { service: 'blog', type: 'post', id: '42' }, owner: C, fallback: { requirement: 'member' } });++ assert.deepStrictEqual([d.allow, d.reason], [true, 'member']);++ assert.strictEqual(await vip.isMember(M, C), true);++ });++++ await check('every failure is a denial, never an exception or a yes', async () => {++ for (const mode of ['down', 'garbage']) {++ const s = stubVip(); s.mode = mode;++ const vip = createVipClient({ tokenClient: s.tokenClient, fetch: s.fetch, log: { warn() {} } });++ const d = await vip.evaluate({ subject: M, resource: { service: 'blog', type: 'post', id: '1' }, owner: C });++ assert.strictEqual(d.allow, false, mode);++ const e = await vip.checkEntitlement({ subject: M, creator: C });++ assert.strictEqual(e.active, false, mode);++ assert.strictEqual(await vip.isMember(M, C), false, mode);++ }++ });++++ await check('a refused token is invalidated and retried once', async () => {++ const s = stubVip(); s.mode = 'refuse-once';++ const vip = createVipClient({ tokenClient: s.tokenClient, fetch: s.fetch });++ assert.strictEqual((await vip.checkEntitlement({ subject: M, creator: C })).active, true);++ assert.strictEqual(s.invalidated, 1);++ assert.deepStrictEqual(s.calls.map((c) => c.auth), ['Bearer t1', 'Bearer t2']);++ });++++ await check('the cache holds a yes for ttlMs and drops it at once on vip.membership.changed', async () => {++ const s = stubVip();++ let t = 1_000_000;++ const cache = createVipCache({ vip: createVipClient({ tokenClient: s.tokenClient, fetch: s.fetch }), ttlMs: 30_000, now: () => t });++ assert.strictEqual((await cache.entitlement({ subject: M, creator: C })).active, true);++ await cache.entitlement({ subject: M, creator: C });++ assert.strictEqual(s.calls.length, 1, 'answered from the cache');++ s.active = false;++ const dropped = cache.handleEvent({ event_type: 'vip.membership.changed', payload: { member: { type: 'user', id: M }, creator: { type: 'user', id: C }, status: 'canceled' } });++ assert.strictEqual(dropped, true);++ assert.strictEqual((await cache.entitlement({ subject: M, creator: C })).active, false, 'revocation converges immediately');++ assert.strictEqual(cache.handleEvent({ event_type: 'something.else', payload: {} }), false);++ t += 31_000;++ assert.ok(cache.bounds && typeof cache.bounds === 'object');++ });++++ await check('a signed-out viewer is never a member and costs no call', async () => {++ const s = stubVip();++ const cache = createVipCache({ vip: createVipClient({ tokenClient: s.tokenClient, fetch: s.fetch }) });++ assert.strictEqual((await cache.entitlement({ subject: null, creator: C })).active, false);++ assert.strictEqual(s.calls.length, 0);++ });++++ console.log(`vip: ${n} checks passed`);++})().catch((e) => { console.error(e); process.exit(1); });@@@ -10,3 +10,4 @@ export * as community from './community';export * as jobs from './jobs';export * as tools from './tools';export * as projects from './projects';++export * as vip from './vip';nnew file mode 100644@@@ -0,0 +1,30 @@++/** openvibe-sdk/vip: the consumer seam for products that honour OpenVibe.VIP memberships (server-side). */++export interface VipTokenClient { authHeaders(): Promise<Record<string, string>>; invalidate?(): void }++export interface VipClientOptions {++ baseUrl?: string;++ tokenClient?: VipTokenClient | null;++ getToken?: (() => Promise<string>) | null;++ fetch?: typeof fetch;++ timeoutMs?: number;++ log?: { warn(...a: unknown[]): void } | null;++}++export interface VipRef { service: string; type: string; id: string }++export interface VipDecision { allow: boolean; reason: string; [k: string]: unknown }++export interface VipEntitlement { status: 'active' | 'inactive' | 'unknown' | string; active: boolean; product_perks?: unknown[]; [k: string]: unknown }++export interface VipClient {++ evaluate(input: { subject?: string | null; resource: VipRef; owner?: string | null; ruleId?: string; mode?: 'projection' | 'authoritative'; fallback?: { requirement: string; binding?: string } }): Promise<VipDecision>;++ checkEntitlement(input: { subject: string; creator: string; mode?: 'projection' | 'authoritative'; product?: string }): Promise<VipEntitlement>;++ isMember(subject: string, creator: string, opts?: { mode?: 'projection' | 'authoritative' }): Promise<boolean>;++ [k: string]: unknown;++}++export interface VipCache {++ entitlement(input: { subject: string; creator: string; product?: string }): Promise<VipEntitlement>;++ peekEntitlement(input: { subject: string; creator: string; product?: string }): VipEntitlement | undefined;++ evaluate(input: { subject?: string | null; resource: VipRef; owner?: string | null; fallback?: { requirement: string; binding?: string }; mode?: string }): Promise<VipDecision>;++ invalidate(input: { member: string; creator: string }): void;++ handleEvent(envelope: unknown): boolean;++ clear(): void;++ readonly bounds: Record<string, number>;++}++export function createVipClient(options?: VipClientOptions): VipClient;++export function createVipCache(options: { vip: VipClient; ttlMs?: number; denyTtlMs?: number; unavailableTtlMs?: number; maxEntries?: number; now?: () => number }): VipCache;
@@ -3,6 +3,16 @@
@@ -3,6 +3,16 @@
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.7.0 (2026-09-24)
+
+`openvibe-sdk/vip`: the consumer seam for products that honour OpenVibe.VIP memberships
+(`createVipClient`, `createVipCache`), published once here instead of the verbatim copies of VIP's
+`openvibe-vip/client` that Chat, Community and Blog carried. Server-only (`"browser": null`); the
+same API and fail-closed behaviour: VIP unreachable, a timeout, a refused token or a malformed answer
+is a denial, a refused token is invalidated and retried once, and the cache drops a member's answers
+for a creator on `vip.membership.changed` (and Billing's entitlement, cancellation and reversal
+events) instead of waiting out its TTL. Additive.
+
## 0.6.0 (2026-09-23)
## 0.6.0 (2026-09-23)
The OpenVibe.Tools platform API (ADR-027, openvibe-contracts v0.33.0) as `openvibe-sdk/tools`, and
The OpenVibe.Tools platform API (ADR-027, openvibe-contracts v0.33.0) as `openvibe-sdk/tools`, and
@@ -167,6 +167,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
@@ -167,6 +167,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `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 |
| `openvibe-sdk/tools` | both | `createToolsClient(client, {baseUrl?, anonymousReads?})`: `list({family, q, execution, api, status})`, `get(id)`, `schema(id)`, `run(id, input, {files, waitMs, idempotencyKey, signal, timeoutMs})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()` |
| `openvibe-sdk/tools` | both | `createToolsClient(client, {baseUrl?, anonymousReads?})`: `list({family, q, execution, api, status})`, `get(id)`, `schema(id)`, `run(id, input, {files, waitMs, idempotencyKey, signal, timeoutMs})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()` |
| `openvibe-sdk/projects` | both (user token) | `createProjectsClient(client)`: `catalog`, `list`, `create`, `get`, `update`, `archive`, `setAllowance`, `setEnvironmentPolicy`, `members.*`, `apps.*`, `credentials.list/rotate/revoke`, `grants.list/request/approve/deny/revoke`, `quotas.*`, `audit`, `iterateAudit` |
| `openvibe-sdk/projects` | both (user token) | `createProjectsClient(client)`: `catalog`, `list`, `create`, `get`, `update`, `archive`, `setAllowance`, `setEnvironmentPolicy`, `members.*`, `apps.*`, `credentials.list/rotate/revoke`, `grants.list/request/approve/deny/revoke`, `quotas.*`, `audit`, `iterateAudit` |
+| `openvibe-sdk/vip` | server | `createVipClient({baseUrl?, tokenClient \| getToken, fetch?, timeoutMs?})`: `evaluate({subject, resource, owner, fallback, mode})`, `checkEntitlement({subject, creator, product, mode})`, `isMember(subject, creator)`; every failure is a denial. `createVipCache({vip, ttlMs, denyTtlMs, unavailableTtlMs})`: `entitlement`, `peekEntitlement`, `evaluate`, `invalidate`, `handleEvent` (`vip.membership.changed` and Billing's entitlement events drop a member's answers at once), `clear`, `bounds` |
| `openvibe-sdk/testing` | Node | `createMockPlatform()`: fake Network (incl. developer apps and projects), Events, Media, Tools jobs and the Tools platform API on an in-process `fetch` |
| `openvibe-sdk/testing` | Node | `createMockPlatform()`: fake Network (incl. developer apps and projects), Events, Media, Tools jobs and the Tools platform API on an in-process `fetch` |
| `openvibe-sdk/browser/openvibe-sdk.mjs` | browser | one self-contained ES module: core + auth (browser), registry, modules, realtime, media, community, jobs, tools, projects |
| `openvibe-sdk/browser/openvibe-sdk.mjs` | browser | one self-contained ES module: core + auth (browser), registry, modules, realtime, media, community, jobs, tools, projects |
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.6.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.7.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, jobs, tools, projects). No server-only code: no service tokens, JWT verification, events, identity.
// (auth, registry, modules, realtime, media, community, jobs, tools, projects). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
/* eslint-disable */
@@ -37,7 +37,7 @@ const { paginate, offsetPager } = __load(7);
@@ -37,7 +37,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.6.0';
+const SDK_VERSION = '0.7.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -6,6 +6,6 @@ export const {
@@ -6,6 +6,6 @@ export const {
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, identity, modules, events, realtime, media,
registry, identity, modules, events, realtime, media,
- community, jobs, tools, projects,
+ community, jobs, tools, projects, vip,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+// Generated by scripts/esm.js from src/vip.js. Do not edit.
+import cjs from '../src/vip.js';
+
+export const {
+ createVipClient, createVipCache,
+} = cjs;
+export default cjs;
@@ -19,4 +19,5 @@ module.exports = {
@@ -19,4 +19,5 @@ module.exports = {
jobs: require('./src/jobs'),
jobs: require('./src/jobs'),
tools: require('./src/tools'),
tools: require('./src/tools'),
projects: require('./src/projects'),
projects: require('./src/projects'),
+ vip: require('./src/vip'),
};
};
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.6.0",
+ "version": "0.7.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.6.0",
+ "version": "0.7.0",
"license": "MIT",
"license": "MIT",
"devDependencies": {
"devDependencies": {
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.6.0",
- "description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Tools (registry, run API and jobs) and developer projects, with deadlines, safe retries, problem errors and trace propagation.",
+ "version": "0.7.0",
+ "description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Tools (registry, run API and jobs), VIP memberships and developer projects, 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",
"browser": "browser.js",
"browser": "browser.js",
@@ -80,6 +80,12 @@
@@ -80,6 +80,12 @@
"import": "./esm/projects.mjs",
"import": "./esm/projects.mjs",
"default": "./src/projects.js"
"default": "./src/projects.js"
},
},
+ "./vip": {
+ "types": "./types/vip.d.ts",
+ "browser": null,
+ "import": "./esm/vip.mjs",
+ "default": "./src/vip.js"
+ },
"./testing": {
"./testing": {
"types": "./types/testing.d.ts",
"types": "./types/testing.d.ts",
"browser": null,
"browser": null,
@@ -26,6 +26,7 @@ const ENTRIES = {
@@ -26,6 +26,7 @@ const ENTRIES = {
'jobs.mjs': '../src/jobs.js',
'jobs.mjs': '../src/jobs.js',
'tools.mjs': '../src/tools.js',
'tools.mjs': '../src/tools.js',
'projects.mjs': '../src/projects.js',
'projects.mjs': '../src/projects.js',
+ 'vip.mjs': '../src/vip.js',
'testing.mjs': '../src/testing/index.js',
'testing.mjs': '../src/testing/index.js',
};
};
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.6.0';
+const SDK_VERSION = '0.7.0';
module.exports = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,236 @@
@@ -0,0 +1,236 @@
+'use strict';
+
+/**
+ * openvibe-sdk/vip — the consumer seam for products that honour OpenVibe.VIP memberships (Live, Chat,
+ * Community, Blog, Wiki, …): VIP's openvibe-vip/client, published once here instead of being copied
+ * into every product. Server-side (it sends a service token); no dependencies beyond fetch.
+ *
+ * const { createVipClient, createVipCache } = require('openvibe-sdk/vip');
+ * const tokens = createServiceTokenClient({ tokenUrl, clientId, clientSecret, audience: 'openvibe.vip' });
+ * const vip = createVipClient({ baseUrl: 'http://127.0.0.1:4620', tokenClient: tokens });
+ *
+ * const d = await vip.evaluate({ subject: viewerSubjectOrNull, resource: { service: 'blog', type: 'post', id: '42' }, owner: authorSubject,
+ * fallback: { requirement: 'member', binding: 'blog:gated_post' } }); // the product's default gate
+ * if (!d.allow) return renderTeaser(d.reason); // FAILS CLOSED
+ *
+ * const e = await vip.checkEntitlement({ subject: 'usr_…', creator: 'usr_…', product: 'chat' }); // e.active, e.product_perks
+ * await vip.isMember('usr_…', 'usr_…'); // boolean, false on any doubt
+ *
+ * tokenClient is anything with authHeaders() (and optionally invalidate()): openvibe-sdk/auth's
+ * createServiceTokenClient or openvibe-contracts serviceAuth.createTokenClient.
+ *
+ * Every failure — VIP unreachable, a timeout, a refused token, a malformed answer — is a denial
+ * ({ allow: false, reason: 'vip_unavailable' } / { status: 'unknown', active: false }), never an
+ * exception and never a "yes". Pass mode: 'authoritative' for sensitive content: VIP then asks
+ * OpenVibe.Billing directly instead of its projection.
+ *
+ * Put createVipCache (below) in front of it: it holds the convergence bound every product shares.
+ */
+
+function createVipClient({ baseUrl = 'http://127.0.0.1:4620', tokenClient = null, getToken = null, fetch: fetchImpl = globalThis.fetch, timeoutMs = 2000, log = null } = {}) {
+ if (typeof fetchImpl !== 'function') throw new TypeError('createVipClient: a fetch implementation is required');
+ const base = String(baseUrl).replace(/\/+$/, '');
+
+ async function headers() {
+ if (tokenClient && typeof tokenClient.authHeaders === 'function') return tokenClient.authHeaders();
+ if (typeof getToken === 'function') return { Authorization: `Bearer ${await getToken()}` };
+ throw new Error('createVipClient: tokenClient or getToken is required');
+ }
+
+ async function post(path, body, retried = false) {
+ const res = await fetchImpl(`${base}${path}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await headers()) },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ if (res.status === 401 && !retried && tokenClient && typeof tokenClient.invalidate === 'function') { tokenClient.invalidate(); return post(path, body, true); }
+ const data = await res.json().catch(() => null);
+ if (!res.ok || !data || typeof data !== 'object') throw new Error(`VIP ${res.status}: ${(data && (data.code || data.detail)) || 'bad response'}`);
+ return data;
+ }
+ const note = (what, err) => { if (log && log.warn) log.warn(`[vip-client] ${what} failed closed: ${err.message}`); };
+
+ /** May `subject` (a usr_ id, a SubjectRef, or null for a signed-out viewer) see `resource`? */
+ async function evaluate({ subject = null, resource, owner = null, ruleId, mode, fallback } = {}) {
+ try {
+ const d = await post('/api/v1/policies/evaluate', { subject: subject || null, resource, owner, rule_id: ruleId, mode, fallback });
+ return { ...d, allow: d.allow === true, reason: d.reason || (d.allow === true ? 'member' : 'denied') };
+ } catch (err) {
+ note('evaluate', err);
+ return { allow: false, reason: 'vip_unavailable', error: err.message };
+ }
+ }
+
+ /** The entitlement of `subject` to `creator` (usr_ ids or SubjectRefs). */
+ async function checkEntitlement({ subject, creator, mode, product } = {}) {
+ try {
+ const e = await post('/api/v1/entitlements/check', { subject, creator, mode, product });
+ return { ...e, active: e.active === true && e.status === 'active' };
+ } catch (err) {
+ note('checkEntitlement', err);
+ return { status: 'unknown', active: false, reason: 'vip_unavailable', error: err.message };
+ }
+ }
+
+ const isMember = async (subject, creator, opts = {}) => (await checkEntitlement({ subject, creator, mode: opts.mode })).active;
+
+ return { evaluate, checkEntitlement, isMember };
+}
+
+/**
+ * createVipCache({ vip, ttlMs, denyTtlMs, unavailableTtlMs, maxEntries, now }) — the product-side
+ * cache every consumer puts in front of the client, with ONE convergence rule:
+ *
+ * A cached "yes" lives at most ttlMs (and never past the entitlement's expires_at or VIP's
+ * valid_until). So once VIP stops granting — it has applied Billing's billing.entitlement.changed
+ * and emitted vip.membership.changed — the product stops granting within ttlMs. handleEvent(envelope)
+ * with either event (or billing.subscription.canceled / billing.transaction.reversed) drops that
+ * member's entries for that creator at once, so a product that receives the events converges
+ * immediately. A "no" lives denyTtlMs (a new member waits at most that long); a failure
+ * (vip_unavailable / unknown) lives unavailableTtlMs, and a cached "yes" is never extended by a
+ * failure: when VIP is down, entries expire into denials (fail closed).
+ *
+ * await cache.entitlement({ subject, creator, product }) → the checkEntitlement answer (cached)
+ * cache.peekEntitlement({ subject, creator, product }) → the cached answer or undefined; never waits
+ * (a miss starts a fetch in the background)
+ * await cache.evaluate({ subject, resource, owner, fallback, mode }) → the evaluate answer (cached;
+ * mode 'authoritative' is never cached)
+ * cache.invalidate({ member, creator }), cache.handleEvent(envelope), cache.clear()
+ */
+function createVipCache({ vip, ttlMs = 30_000, denyTtlMs = 10_000, unavailableTtlMs = 2_000, maxEntries = 5000, now = () => Date.now() } = {}) {
+ if (!vip || typeof vip.evaluate !== 'function' || typeof vip.checkEntitlement !== 'function') throw new TypeError('createVipCache: a VIP client is required');
+ const entries = new Map(); // key → { value, expires, member, creator }
+ const inflight = new Map(); // key → Promise
+ let generation = 0; // bumped by clear(); an answer fetched before a clear is not stored
+ const pairGen = new Map(); // `${member}|${creator}` → generation of its last invalidation
+
+ const idOf = (v) => (v && typeof v === 'object' ? (v.id != null ? String(v.id) : null) : (v != null && v !== '' ? String(v) : null));
+ const pairKey = (member, creator) => `${member || ''}|${creator || ''}`;
+
+ function lifetime(value, allowed) {
+ const t = now();
+ if (value && (value.reason === 'vip_unavailable' || value.status === 'unknown' || value.reason === 'entitlement_unknown')) return unavailableTtlMs;
+ if (!allowed) return denyTtlMs;
+ let life = ttlMs;
+ const ent = value.entitlement !== undefined ? value.entitlement : value;
+ for (const at of [ent && ent.expires_at, ent && ent.valid_until]) {
+ const ms = at ? Date.parse(at) : NaN;
+ if (Number.isFinite(ms)) life = Math.min(life, ms - t);
+ }
+ if (ent && ent.stale) life = Math.min(life, unavailableTtlMs);
+ return Math.max(0, life);
+ }
+
+ function store(key, value, allowed, member, creator, startedGen, startedPair) {
+ if (startedGen !== generation || (pairGen.get(pairKey(member, creator)) || 0) !== startedPair) return; // invalidated meanwhile
+ const life = lifetime(value, allowed);
+ if (life <= 0) { entries.delete(key); return; }
+ if (entries.size >= maxEntries) {
+ const t = now();
+ for (const [k, e] of entries) if (e.expires <= t) entries.delete(k);
+ while (entries.size >= maxEntries) entries.delete(entries.keys().next().value);
+ }
+ entries.set(key, { value, expires: now() + life, member, creator });
+ }
+
+ function fresh(key) {
+ const e = entries.get(key);
+ if (!e) return undefined;
+ if (e.expires <= now()) { entries.delete(key); return undefined; }
+ return e.value;
+ }
+
+ function load(key, member, creator, fetcher, allowedOf) {
+ const hit = fresh(key);
+ if (hit !== undefined) return Promise.resolve(hit);
+ if (inflight.has(key)) return inflight.get(key);
+ const g = generation;
+ const pg = pairGen.get(pairKey(member, creator)) || 0;
+ const p = (async () => {
+ try {
+ const value = await fetcher();
+ store(key, value, allowedOf(value), member, creator, g, pg);
+ return value;
+ } finally { inflight.delete(key); }
+ })();
+ inflight.set(key, p);
+ return p;
+ }
+
+ function entitlement({ subject, creator, product = null, mode } = {}) {
+ const member = idOf(subject);
+ const owner = idOf(creator);
+ if (!member || !owner) return Promise.resolve({ status: 'inactive', active: false, reason: 'not_signed_in' });
+ if (mode === 'authoritative') return vip.checkEntitlement({ subject: member, creator: owner, mode, product: product || undefined });
+ const key = `e|${member}|${owner}|${product || ''}`;
+ return load(key, member, owner, () => vip.checkEntitlement({ subject: member, creator: owner, product: product || undefined }), (v) => v.active === true);
+ }
+
+ function peekEntitlement(args = {}) {
+ const member = idOf(args.subject);
+ const owner = idOf(args.creator);
+ if (!member || !owner) return undefined;
+ const key = `e|${member}|${owner}|${args.product || ''}`;
+ const hit = fresh(key);
+ if (hit === undefined) entitlement(args).catch(() => {});
+ return hit;
+ }
+
+ function evaluate({ subject = null, resource, owner, fallback, mode } = {}) {
+ const member = idOf(subject);
+ const creator = idOf(owner);
+ if (mode === 'authoritative' || !resource || typeof resource !== 'object') return vip.evaluate({ subject: member, resource, owner: creator, fallback, mode });
+ if (!member) return Promise.resolve({ allow: false, reason: 'not_signed_in' });
+ const fb = fallback == null ? '' : JSON.stringify(fallback);
+ const key = `p|${member}|${creator || ''}|${resource.service}|${resource.type}|${resource.id}|${fb}`;
+ return load(key, member, creator, () => vip.evaluate({ subject: member, resource, owner: creator, fallback }), (v) => v.allow === true);
+ }
+
+ /** Drop what the cache knows about (member, creator); either may be omitted to drop more. */
+ function invalidate({ member = null, creator = null } = {}) {
+ const m = idOf(member);
+ const c = idOf(creator);
+ if (m && c) pairGen.set(pairKey(m, c), (pairGen.get(pairKey(m, c)) || 0) + 1);
+ else generation++;
+ let dropped = 0;
+ for (const [k, e] of entries) {
+ if ((!m || e.member === m) && (!c || e.creator === c)) { entries.delete(k); dropped++; }
+ }
+ return dropped;
+ }
+
+ /**
+ * An OpenVibe.Events envelope about a membership → invalidate that pair. Returns true when the
+ * event was understood. vip.membership.changed { member, creator }; billing.entitlement.changed
+ * { subject, streamer }; billing.subscription.canceled { subscription: { subscriber, streamer } };
+ * billing.transaction.reversed { to_subject (member), from_subject (creator) }.
+ */
+ function handleEvent(envelope) {
+ const e = envelope && envelope.event && envelope.event.event_type ? envelope.event : envelope;
+ const p = (e && e.payload) || {};
+ let member = null;
+ let creator = null;
+ switch (e && e.event_type) {
+ case 'vip.membership.changed': member = idOf(p.member); creator = idOf(p.creator); break;
+ case 'billing.entitlement.changed': member = idOf(p.subject); creator = idOf(p.streamer); break;
+ case 'billing.subscription.canceled': member = idOf(p.subscription && p.subscription.subscriber); creator = idOf(p.subscription && p.subscription.streamer); break;
+ case 'billing.transaction.reversed': member = idOf(p.to_subject); creator = idOf(p.from_subject); break;
+ default: return false;
+ }
+ if (!member || !creator) return false;
+ invalidate({ member, creator });
+ return true;
+ }
+
+ function clear() { generation++; entries.clear(); }
+
+ return {
+ entitlement, peekEntitlement, evaluate, invalidate, handleEvent, clear,
+ get size() { return entries.size; },
+ /** The convergence bound, for docs and /ready: how long a cached "yes" can outlive VIP's "no". */
+ bounds: Object.freeze({ grantMs: ttlMs, denyMs: denyTtlMs, unavailableMs: unavailableTtlMs }),
+ };
+}
+
+module.exports = { createVipClient, createVipCache };
@@ -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(), ['./events', './identity', './testing']);
+ assert.deepEqual(blocked.sort(), ['./events', './identity', './testing', './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/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/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`);
@@ -22,7 +22,7 @@ run([
@@ -22,7 +22,7 @@ run([
const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8' });
const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8' });
const info = JSON.parse(out)[0];
const info = JSON.parse(out)[0];
const files = info.files.map((f) => f.path);
const files = info.files.map((f) => f.path);
- for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'browser/openvibe-sdk.mjs']) {
+ for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'browser/openvibe-sdk.mjs']) {
assert.ok(files.includes(must), `tarball contains ${must}`);
assert.ok(files.includes(must), `tarball contains ${must}`);
}
}
assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');
assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');
@@ -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, 15);
+ assert.equal(subpaths.length, 16);
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,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+// openvibe-sdk/vip: the VIP consumer seam, published once (it was copied into Chat, Community and Blog).
+// Fails closed on every doubt, retries a refused token once, and the cache converges on membership events.
+const assert = require('assert');
+const { createVipClient, createVipCache } = require('../src/vip');
+
+const M = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const C = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
+
+function stubVip() {
+ const s = { calls: [], active: true, mode: 'ok', tokens: 0, invalidated: 0 };
+ s.fetch = async (url, opts) => {
+ const path = new URL(url).pathname;
+ const body = JSON.parse(opts.body || '{}');
+ s.calls.push({ path, body, auth: opts.headers.Authorization });
+ if (s.mode === 'down') throw new Error('ECONNREFUSED');
+ if (s.mode === 'refuse-once') { s.mode = 'ok'; return { ok: false, status: 401, json: async () => ({}) }; }
+ if (s.mode === 'garbage') return { ok: true, status: 200, json: async () => ({ nonsense: true }) };
+ if (path === '/api/v1/entitlements/check') return { ok: true, status: 200, json: async () => ({ status: s.active ? 'active' : 'inactive', active: s.active, product_perks: s.active ? ['badge'] : [] }) };
+ if (path === '/api/v1/policies/evaluate') return { ok: true, status: 200, json: async () => ({ allow: s.active, reason: s.active ? 'member' : 'not_member' }) };
+ return { ok: false, status: 404, json: async () => ({}) };
+ };
+ s.tokenClient = { authHeaders: async () => ({ Authorization: `Bearer t${++s.tokens}` }), invalidate: () => { s.invalidated++; } };
+ return s;
+}
+
+(async () => {
+ let n = 0;
+ const check = async (name, fn) => { await fn(); n++; };
+
+ await check('entitlement and evaluate answer through the service token', async () => {
+ const s = stubVip();
+ const vip = createVipClient({ baseUrl: 'http://vip.test/', tokenClient: s.tokenClient, fetch: s.fetch });
+ const e = await vip.checkEntitlement({ subject: M, creator: C, product: 'chat' });
+ assert.strictEqual(e.active, true);
+ assert.deepStrictEqual(s.calls[0].body, { subject: M, creator: C, product: 'chat' });
+ assert.strictEqual(s.calls[0].auth, 'Bearer t1');
+ const d = await vip.evaluate({ subject: M, resource: { service: 'blog', type: 'post', id: '42' }, owner: C, fallback: { requirement: 'member' } });
+ assert.deepStrictEqual([d.allow, d.reason], [true, 'member']);
+ assert.strictEqual(await vip.isMember(M, C), true);
+ });
+
+ await check('every failure is a denial, never an exception or a yes', async () => {
+ for (const mode of ['down', 'garbage']) {
+ const s = stubVip(); s.mode = mode;
+ const vip = createVipClient({ tokenClient: s.tokenClient, fetch: s.fetch, log: { warn() {} } });
+ const d = await vip.evaluate({ subject: M, resource: { service: 'blog', type: 'post', id: '1' }, owner: C });
+ assert.strictEqual(d.allow, false, mode);
+ const e = await vip.checkEntitlement({ subject: M, creator: C });
+ assert.strictEqual(e.active, false, mode);
+ assert.strictEqual(await vip.isMember(M, C), false, mode);
+ }
+ });
+
+ await check('a refused token is invalidated and retried once', async () => {
+ const s = stubVip(); s.mode = 'refuse-once';
+ const vip = createVipClient({ tokenClient: s.tokenClient, fetch: s.fetch });
+ assert.strictEqual((await vip.checkEntitlement({ subject: M, creator: C })).active, true);
+ assert.strictEqual(s.invalidated, 1);
+ assert.deepStrictEqual(s.calls.map((c) => c.auth), ['Bearer t1', 'Bearer t2']);
+ });
+
+ await check('the cache holds a yes for ttlMs and drops it at once on vip.membership.changed', async () => {
+ const s = stubVip();
+ let t = 1_000_000;
+ const cache = createVipCache({ vip: createVipClient({ tokenClient: s.tokenClient, fetch: s.fetch }), ttlMs: 30_000, now: () => t });
+ assert.strictEqual((await cache.entitlement({ subject: M, creator: C })).active, true);
+ await cache.entitlement({ subject: M, creator: C });
+ assert.strictEqual(s.calls.length, 1, 'answered from the cache');
+ s.active = false;
+ const dropped = cache.handleEvent({ event_type: 'vip.membership.changed', payload: { member: { type: 'user', id: M }, creator: { type: 'user', id: C }, status: 'canceled' } });
+ assert.strictEqual(dropped, true);
+ assert.strictEqual((await cache.entitlement({ subject: M, creator: C })).active, false, 'revocation converges immediately');
+ assert.strictEqual(cache.handleEvent({ event_type: 'something.else', payload: {} }), false);
+ t += 31_000;
+ assert.ok(cache.bounds && typeof cache.bounds === 'object');
+ });
+
+ await check('a signed-out viewer is never a member and costs no call', async () => {
+ const s = stubVip();
+ const cache = createVipCache({ vip: createVipClient({ tokenClient: s.tokenClient, fetch: s.fetch }) });
+ assert.strictEqual((await cache.entitlement({ subject: null, creator: C })).active, false);
+ assert.strictEqual(s.calls.length, 0);
+ });
+
+ console.log(`vip: ${n} checks passed`);
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -10,3 +10,4 @@ export * as community from './community';
@@ -10,3 +10,4 @@ export * as community from './community';
export * as jobs from './jobs';
export * as jobs from './jobs';
export * as tools from './tools';
export * as tools from './tools';
export * as projects from './projects';
export * as projects from './projects';
+export * as vip from './vip';
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
+/** openvibe-sdk/vip: the consumer seam for products that honour OpenVibe.VIP memberships (server-side). */
+export interface VipTokenClient { authHeaders(): Promise<Record<string, string>>; invalidate?(): void }
+export interface VipClientOptions {
+ baseUrl?: string;
+ tokenClient?: VipTokenClient | null;
+ getToken?: (() => Promise<string>) | null;
+ fetch?: typeof fetch;
+ timeoutMs?: number;
+ log?: { warn(...a: unknown[]): void } | null;
+}
+export interface VipRef { service: string; type: string; id: string }
+export interface VipDecision { allow: boolean; reason: string; [k: string]: unknown }
+export interface VipEntitlement { status: 'active' | 'inactive' | 'unknown' | string; active: boolean; product_perks?: unknown[]; [k: string]: unknown }
+export interface VipClient {
+ evaluate(input: { subject?: string | null; resource: VipRef; owner?: string | null; ruleId?: string; mode?: 'projection' | 'authoritative'; fallback?: { requirement: string; binding?: string } }): Promise<VipDecision>;
+ checkEntitlement(input: { subject: string; creator: string; mode?: 'projection' | 'authoritative'; product?: string }): Promise<VipEntitlement>;
+ isMember(subject: string, creator: string, opts?: { mode?: 'projection' | 'authoritative' }): Promise<boolean>;
+ [k: string]: unknown;
+}
+export interface VipCache {
+ entitlement(input: { subject: string; creator: string; product?: string }): Promise<VipEntitlement>;
+ peekEntitlement(input: { subject: string; creator: string; product?: string }): VipEntitlement | undefined;
+ evaluate(input: { subject?: string | null; resource: VipRef; owner?: string | null; fallback?: { requirement: string; binding?: string }; mode?: string }): Promise<VipDecision>;
+ invalidate(input: { member: string; creator: string }): void;
+ handleEvent(envelope: unknown): boolean;
+ clear(): void;
+ readonly bounds: Record<string, number>;
+}
+export function createVipClient(options?: VipClientOptions): VipClient;
+export function createVipCache(options: { vip: VipClient; ttlMs?: number; denyTtlMs?: number; unavailableTtlMs?: number; maxEntries?: number; now?: () => number }): VipCache;