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

OP OpenVibers committed 24 Sept 2026 · 483e93fe76 · 16 files, +391 −11


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex a504bd7..9d33490 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,16 @@
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.
++## 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
ddiff --git a/README.md b/README.md
iindex 6978c00..41f1f5c 100644
---- a/README.md
++++ b/README.md
@@@ -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 |
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 8e42f67..8ff990e 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.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,
ddiff --git a/esm/index.mjs b/esm/index.mjs
iindex 5460ea9..e03c926 100644
---- a/esm/index.mjs
++++ b/esm/index.mjs
@@@ -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;
ddiff --git a/esm/vip.mjs b/esm/vip.mjs
nnew file mode 100644
iindex 0000000..058aa2d
---- /dev/null
++++ b/esm/vip.mjs
@@@ -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;
ddiff --git a/index.js b/index.js
iindex b69ca35..4c730b8 100644
---- a/index.js
++++ b/index.js
@@@ -19,4 +19,5 @@ module.exports = {
jobs: require('./src/jobs'),
tools: require('./src/tools'),
projects: require('./src/projects'),
++ vip: require('./src/vip'),
};
ddiff --git a/package-lock.json b/package-lock.json
iindex e057a64..eef1b47 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 2835fb0..42b2dd0 100644
---- a/package.json
++++ b/package.json
@@@ -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,
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex aa1fba2..543fe3b 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -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',
};
ddiff --git a/src/core/index.js b/src/core/index.js
iindex b7599d2..12f181d 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.6.0';
++const SDK_VERSION = '0.7.0';
module.exports = {
createClient,
ddiff --git a/src/vip.js b/src/vip.js
nnew file mode 100644
iindex 0000000..5693aba
---- /dev/null
++++ b/src/vip.js
@@@ -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 };
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex 5767f10..8369cf5 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(), ['./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`);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex c2076ac..4169c23 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -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 });
ddiff --git a/test/vip.test.js b/test/vip.test.js
nnew file mode 100644
iindex 0000000..c423522
---- /dev/null
++++ b/test/vip.test.js
@@@ -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); });
ddiff --git a/types/index.d.ts b/types/index.d.ts
iindex 1464a9a..0d4a323 100644
---- a/types/index.d.ts
++++ b/types/index.d.ts
@@@ -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';
ddiff --git a/types/vip.d.ts b/types/vip.d.ts
nnew file mode 100644
iindex 0000000..47a28a3
---- /dev/null
++++ b/types/vip.d.ts
@@@ -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;