openvibers/openvibe.vip Commits
Consumers can gate on VIP: the product's default gate, product perks and a shared product cache with a tested convergence bound
Changes
@@@ -62,7 +62,8 @@ home that is not a cache.- **OpenVibe.Events** — delivery of Billing's events to `/internal/events`; relay of VIP's outbox.- **openvibe-contracts** v0.19.0, **openvibe-sdk** v0.4.0 (outbox, inbox, delivery signatures),**openvibe-shared** v1.3.0 (chrome, legal pages, release, metrics, readiness).--- Consumers (not wired yet): Live, Chat, Community, Blog, Wiki — through the client below.++- Consumers, through the client below: Chat (the member badge), Community (members-only spaces and++ threads) and Blog (members-only posts); Live and Wiki are not wired yet.- **Not** OpenVibe.Live: VIP works with Live unavailable (nothing here calls Live).## Run it@@@ -71,7 +72,7 @@ home that is not a cache.fnm exec --using=22.22.1 npm installcp .env.example .env # OV_OAUTH_CLIENT_SECRET, VIP_FORM_SECRET, VIP_EVENTS_SECRET, BILLING_URL …fnm exec --using=22.22.1 npm run dev # http://localhost:4620--fnm exec --using=22.22.1 npm test # 5 test files: stub Network + Billing, temp DBs, random ports, injected clock++fnm exec --using=22.22.1 npm test # 7 test files: stub Network + Billing, temp DBs, random ports, injected clocknode scripts/subscribe.js # create the three Events subscriptions for Billing's eventsnode scripts/import-live.js --live-db <live snapshot> [--billing-db <billing snapshot>] [--dry-run] [--json]```@@@ -168,15 +169,24 @@ handle or `network`.| `GET /memberships/:subject[?mode=]` | `vip.membership.status` | self | memberships, version bought under, entitlement, preferences || `POST /memberships/:creator/cancel` | — | self only | cancel at period end through Billing || `PUT /memberships/:creator/preferences` | — | self only | `{ show_badge, listed }` |--| `GET\|POST /entitlements/check` `{subject, creator, mode}` | `vip.entitlement.check` | self | `{ status: active\|inactive\|unknown, active, expires_at, cancel_at_period_end, source, stale, valid_until, membership }` |++| `GET\|POST /entitlements/check` `{subject, creator, mode, product?}` | `vip.entitlement.check` | self | `{ status: active\|inactive\|unknown, active, expires_at, cancel_at_period_end, source, stale, valid_until, membership }`; with `product` also `product_perks` (the member's version perks bound to that product, with each binding's config, e.g. Chat's `badge`) and `preferences` (`show_badge`) || `GET /policies?service=&type=&id=&owner=` / `?creator=` (services must name `owner`; a person defaults to themselves) | `vip.resource.policy.get` | owner | the rule for a resource / a creator's rules || `POST /policies`, `DELETE /policies/:id` | `vip.resource.policy.set` | owner | gate / un-gate a resource `{resource, requirement: member\|plan\|perk, plan_id, perk_key, sensitive}` |--| `POST /policies/evaluate` `{subject, resource, owner, rule_id?, mode?}` — `owner` = the creator the PRODUCT says owns the resource (usr_ id or `network`); only that creator's rule applies, none → `owner_required` | `vip.resource.policy.evaluate` | self | `{ allow, reason, rule, entitlement }` — **fails closed** |++| `POST /policies/evaluate` `{subject, resource, owner, rule_id?, mode?, fallback?}` — `owner` = the creator the PRODUCT says owns the resource (usr_ id or `network`); only that creator's rule applies, none → `owner_required`. `fallback` `{ requirement: 'member', binding?: 'product:binding' }` = the product's default gate, used only when the owner has no rule (below) | `vip.resource.policy.evaluate` | self | `{ allow, reason, rule, entitlement, fallback? }` — **fails closed** || `GET /creators/:ref/members` | `vip.creator.members.list` | owner / staff | active members with their plan version; from Billing, or the labelled projection when Billing is down |Evaluate reasons: `member`, `owner`, `no_rule`, `rule_mismatch`, `rule_disabled`, `invalid_resource`,--`creator_unavailable`, `not_signed_in`, `not_a_member`, `entitlement_unknown`, `plan_required`,--`perk_missing`. Only `allow: true` allows.++`invalid_fallback`, `creator_unavailable`, `not_signed_in`, `not_a_member`, `entitlement_unknown`,++`plan_required`, `perk_missing`. Only `allow: true` allows.++++**The product's default gate (`fallback`).** A product that marks its own resource members-only (a Blog++post, a Community space or thread) does not register a VIP rule per resource. It asks with++`fallback: { requirement: 'member', binding: 'blog:gated_post' }`, which applies only when the owner++has no active rule for that resource — a rule the creator sets in VIP (plan or perk requirement,++sensitive) always wins. Under the fallback the viewer needs an active entitlement to the owner and,++when the owner defines an active perk carrying that product binding, a plan version that includes one++of those perks (`perk_missing` otherwise). A creator VIP has never seen is asked of Billing as is.++The answer has `fallback: true` and `rule: null`.## Consumer seam: `openvibe-vip/client`@@@ -198,7 +208,39 @@ await vip.checkEntitlement({ subject, creator, mode: 'authoritative' }); // sens```Until the SDK grows a `vip` module, consumers pin this repository's tag tarball and require only--`openvibe-vip/client` (it loads nothing else); moving it into openvibe-sdk is the intended home.++`openvibe-vip/client` (it loads nothing else), or vendor the file verbatim with the commit it was++copied from (Chat, Community and Blog do, in `server/vip/vip-client.js`, until a VIP tag is published);++moving it into openvibe-sdk is the intended home.++++### The product cache and the convergence bound++++Every consumer puts `createVipCache` (same module) in front of the client, so the rule for how long++a product may keep granting after a membership ends is written once:++++```js++const { createVipClient, createVipCache } = require('openvibe-vip/client');++const cache = createVipCache({ vip, ttlMs: 30_000, denyTtlMs: 10_000, unavailableTtlMs: 2_000 });++await cache.evaluate({ subject, resource, owner, fallback }); // Community, Blog++await cache.entitlement({ subject, creator, product: 'chat' }); // Chat's badge++cache.peekEntitlement({ subject, creator, product: 'chat' }); // never waits (a miss warms it)++cache.handleEvent(envelope); // vip.membership.changed, billing.entitlement.changed, …++```++++When a membership changes (Billing emits `billing.entitlement.changed`, VIP applies it and emits++`vip.membership.changed`), a product stops granting within:++++| Situation | Bound |++|---|---|++| the product hands the event to `cache.handleEvent` | at once (the member–creator pair is dropped; an answer in flight is not stored) |++| VIP has the event, the product does not | `ttlMs` (a cached "yes" lives at most that long) |++| every event lost, VIP's included | `VIP_PROJECTION_MAX_AGE_MS` + `ttlMs` (15 min + the product's TTL by default); with Billing down as well, add `VIP_PROJECTION_GRACE_MS`, after which VIP answers `unknown` |++| cancel at period end / the period ends | never past `expires_at`: a "yes" is not cached beyond the entitlement's `expires_at` or VIP's `valid_until` |++| VIP unreachable | `ttlMs`, then every answer is a denial (a failure never extends a "yes") |++++A "no" is cached `denyTtlMs` (a new member waits at most that long); failures `unavailableTtlMs`.++[test/convergence.test.js](test/convergence.test.js) proves every row against the real VIP with an++injected clock. The products' TTLs: Chat 60 s (a badge), Community 30 s, Blog 30 s — each product's++README repeats its bound and its own test drives the product through a change.Live's badge mapping (`live chat_badge`, `live powerchat_overlay`, `live ai_context`) and Chat's(`chat badge`) are declared as product bindings of the network perk `subscriber-badge`.@@@ -10,7 +10,8 @@"plans, immutable plan versions, perks, product bindings, member preferences and gated-resource rules in VIP's own SQLite","entitlement projection fed by Billing events (signed webhook + openvibe-sdk inbox) with valid_until, grace and a direct Billing fallback; 'unknown' never authorizes","checkout hand-off and cancellation through OpenVibe.Billing; the membership is filed under the plan version chosen",-- "API /api/v1 for service and user tokens; consumer client openvibe-vip/client that fails closed",++ "API /api/v1 for service and user tokens; consumer client openvibe-vip/client that fails closed, with createVipCache (the product cache and its convergence bound, test/convergence.test.js)",++ "the product's default gate (evaluate fallback, honouring product bindings such as blog gated_post) and entitlements/check product perks (Chat's badge); Chat, Community and Blog consume them in code (not deployed, grants pending)","server-rendered creator plan pages, terms history, member status and creator dashboard with the shared chrome","outbox events vip.plan.published and vip.membership.changed (openvibe-sdk)","import of Live's subscription offering, memberships from Billing entitlements",@@@ -21,7 +22,7 @@"not launched: openvibe.vip is still an OpenVibe.Sites placeholder and the service answers on loopback only","checkout is closed until the Billing cutover (Billing runs in shadow; the owner must re-point the PowerChat webhook first); no billing.* event has reached VIP","the Live subscription import (scripts/import-live.js) has not been run (vip_migration_maps 0)",-- "Live, Chat, Community, Blog and Wiki do not call VIP yet (no client, no vip.* grants for them)",++ "Chat, Community and Blog call VIP in code only: not deployed, and the Network has no vip.* grants for them yet; Live and Wiki do not call VIP","membership badge, card and widget endpoints","Billing sells one channel subscription per member and creator: one purchasable plan per creator, no tiers, no network membership product"],@@@ -9,16 +9,19 @@* audience: 'openvibe.vip', scope: 'vip.resource.policy.evaluate vip.entitlement.check' });* 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 });++ * 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_…' }); // e.active++ * 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** 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 } = {}) {@@@ -46,9 +49,9 @@ function createVipClient({ baseUrl = 'http://127.0.0.1:4620', tokenClient = nullconst 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 } = {}) {++ 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 });++ 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);@@@ -57,9 +60,9 @@ function createVipClient({ baseUrl = 'http://127.0.0.1:4620', tokenClient = null}/** The entitlement of `subject` to `creator` (usr_ ids or SubjectRefs). */-- async function checkEntitlement({ subject, creator, mode } = {}) {++ async function checkEntitlement({ subject, creator, mode, product } = {}) {try {-- const e = await post('/api/v1/entitlements/check', { subject, creator, mode });++ 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);@@@ -72,4 +75,159 @@ function createVipClient({ baseUrl = 'http://127.0.0.1:4620', tokenClient = nullreturn { evaluate, checkEntitlement, isMember };}--module.exports = { createVipClient };++/**++ * 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 };@@@ -253,7 +253,18 @@ function v1Router({ domain, apiAuth }) {creatorSubject = c.subject;}const mode = ['projection', 'authoritative'].includes(src.mode) ? src.mode : 'auto';-- res.json(await entitlements.check(member, creatorSubject, { mode, traceparent: trace(req) }));++ const out = await entitlements.check(member, creatorSubject, { mode, traceparent: trace(req) });++ // `product`: what this membership means in one product — the perks of the member's plan version++ // with that product's bindings (a chat badge, a gated post, …) and the member's badge preference.++ if (src.product != null && src.product !== '') {++ const product = String(src.product);++ if (!/^[a-z][a-z0-9-]{1,39}$/.test(product)) fail(422, 'vip.invalid_input', 'product must be a service id such as chat or blog');++ out.product = product;++ out.product_perks = out.active ? perks.forMembership(out.membership, product) : [];++ const c = creators.bySubject(creatorSubject);++ out.preferences = c ? memberships.preferences(member, c.id) : { show_badge: true, listed: false };++ }++ res.json(out);});r.get('/entitlements/check', entitlementCheck);r.post('/entitlements/check', entitlementCheck);@@@ -312,7 +323,7 @@ function v1Router({ domain, apiAuth }) {const s = b.subject && typeof b.subject === 'object' ? b.subject.id : b.subject;subject = s ? String(s) : null;}-- const out = await policies.evaluate({ subject, resource: b.resource, owner: b.owner || null, ruleId: b.rule_id, mode: b.mode === 'authoritative' ? 'authoritative' : 'auto', traceparent: trace(req) });++ const out = await policies.evaluate({ subject, resource: b.resource, owner: b.owner || null, ruleId: b.rule_id, mode: b.mode === 'authoritative' ? 'authoritative' : 'auto', fallback: b.fallback || null, traceparent: trace(req) });res.json(out);}));@@@ -102,6 +102,24 @@ function createPerks({ db, now }) {const bindingsOf = (perkId) => db.prepare("SELECT * FROM vip_product_bindings WHERE perk_id = ? AND status = 'active' ORDER BY product, binding").all(perkId);++ /**++ * The perks a presented membership's plan version grants, with their active bindings for one++ * product: [{ key, name, kind, scope, bindings: [{ binding, config }] }] (perks without such a++ * binding are left out). The version's snapshot names the keys; a key resolves to the creator's++ * own perk first, then the network's.++ */++ function forMembership(membership, product) {++ if (!membership || !Array.isArray(membership.perks) || !membership.perks.length) return [];++ const out = [];++ for (const key of membership.perks) {++ const p = byKey(membership.creator_id, key) || byKey('network', key);++ if (!p || p.status !== 'active') continue;++ const bindings = bindingsOf(p.id).filter((b) => b.product === product).map((b) => ({ binding: b.binding, config: json(b.config, {}) }));++ if (bindings.length) out.push({ key: p.key, name: p.name, kind: p.kind, scope: p.creator_id === 'network' ? 'network' : 'creator', bindings });++ }++ return out;++ }++function present(p, { withBindings = true } = {}) {if (!p) return null;return {@@@ -112,7 +130,7 @@ function createPerks({ db, now }) {};}-- return { byId, byKey, create, update, list, resolveForPlan, bindingsOf, present, KINDS };++ return { byId, byKey, create, update, list, resolveForPlan, bindingsOf, forMembership, present, KINDS };}module.exports = { createPerks };@@@ -13,6 +13,14 @@* evaluate() FAILS CLOSED: no owner, no rule, a disabled rule, a suspended creator, an entitlement Billing* cannot confirm ("unknown"), a missing perk or any error → allow: false with the reason. Rules* marked sensitive always ask Billing directly (never the projection).++ *++ * The product's default gate (`fallback`): a product that marks its own resource members-only (a Blog++ * post, a Community space or thread) need not register a rule for each one. It passes++ * fallback { requirement: 'member', binding: 'product:binding' }, which applies ONLY when the owner has++ * no active rule for the resource (the creator's own rule always wins): the viewer must hold an active++ * entitlement to the owner, and — when the owner defines an active perk with that product binding++ * (e.g. `blog gated_post`) — the membership's plan version must include one of those perks. Every++ * doubt still denies; the answer carries `fallback: true` and `rule: null`.*/const { fail, prefixedId, iso, entityRef, isUserSubject, text } = require('../util');@@@ -74,18 +82,67 @@ function createPolicies({ db, now, creators, plans, perks, memberships, entitlemconst deny = (reason, extra = {}) => ({ allow: false, reason, ...extra });++ /** A product's default gate → { requirement: 'member', binding: { product, binding } | null }, or null when absent. */++ function cleanFallback(f) {++ if (f == null || f === false) return null;++ const v = typeof f === 'string' ? { requirement: f } : f;++ if (!v || typeof v !== 'object' || v.requirement !== 'member') fail(422, 'vip.invalid_input', 'fallback must be { requirement: "member", binding?: "product:binding" }');++ if (v.binding == null || v.binding === '') return { requirement: 'member', binding: null };++ const m = /^([a-z][a-z0-9-]{1,39})[: ]([a-z][a-z0-9_.]{1,63})$/.exec(String(v.binding));++ if (!m) fail(422, 'vip.invalid_input', 'fallback.binding must be "product:binding", such as blog:gated_post');++ return { requirement: 'member', binding: { product: m[1], binding: m[2] } };++ }++++ /** The owner's active perks (own and network) that carry a product binding: the keys a member must hold. */++ function boundPerkKeys(creatorId, { product, binding }) {++ return perks.list({ creatorId, includeNetwork: true })++ .filter((p) => perks.bindingsOf(p.id).some((b) => b.product === product && b.binding === binding))++ .map((p) => p.key);++ }++++ /** evaluate() when the owner has no rule and the product passed its default gate. */++ async function evaluateFallback({ subject, oc, fb, mode, traceparent }) {++ const base = { rule: null, fallback: true };++ if (oc.status !== 'active' || !oc.subject) return deny('creator_unavailable', base);++ if (!subject) return deny('not_signed_in', base);++ if (!isUserSubject(subject)) return deny('not_a_member', base);++ if (subject === oc.subject) return { allow: true, reason: 'owner', ...base, entitlement: null };++ let ent;++ try {++ ent = await entitlements.check(subject, oc.subject, { mode: mode === 'authoritative' ? 'authoritative' : 'auto', traceparent });++ } catch { return deny('entitlement_unknown', base); }++ if (ent.status === 'unknown') return deny('entitlement_unknown', { ...base, entitlement: ent });++ if (!ent.active) return deny('not_a_member', { ...base, entitlement: ent });++ if (fb.binding) {++ const needed = boundPerkKeys(oc.id, fb.binding);++ if (needed.length) {++ const held = memberships.perkKeys(memberships.get(subject, oc.id));++ if (!needed.some((k) => held.includes(k))) return deny('perk_missing', { ...base, entitlement: ent });++ }++ }++ return { allow: true, reason: 'member', ...base, entitlement: ent };++ }++/*** May `subject` see `resource`, owned (says the product) by creator `owner`? { allow, reason, rule,* entitlement }. `ruleId` pins the rule the product believes applies (a mismatch denies).* mode: 'auto' (default) or 'authoritative'.*/-- async function evaluate({ subject, resource, owner = null, ruleId = null, mode = 'auto', traceparent }) {++ async function evaluate({ subject, resource, owner = null, ruleId = null, mode = 'auto', fallback = null, traceparent }) {let r;try { r = entityRef(resource); } catch { return deny('invalid_resource'); }++ let fb;++ try { fb = cleanFallback(fallback); } catch { return deny('invalid_fallback'); }if (!owner) return deny('owner_required');-- const oc = ownerCreator(owner);++ let oc = ownerCreator(owner);++ // A creator VIP has never seen can still hold Billing subscriptions: the default gate asks for them.++ if (!oc && fb && !ruleId && owner !== 'network') {++ const s = owner && typeof owner === 'object' ? owner.id : owner;++ if (isUserSubject(String(s || ''))) oc = { id: null, subject: String(s), status: 'active', kind: 'creator' };++ }if (!oc) return deny('no_rule');-- const rule = ruleId ? byId(String(ruleId)) : forResource(r, oc.id);++ const rule = ruleId ? byId(String(ruleId)) : (oc.id ? forResource(r, oc.id) : null);++ if (!rule && fb && !ruleId) return evaluateFallback({ subject, oc, fb, mode, traceparent });if (!rule) return deny('no_rule');if (ruleId && (rule.resource_service !== r.service || rule.resource_type !== r.type || rule.resource_id !== r.id)) return deny('rule_mismatch', { rule: present(rule) });if (rule.creator_id !== oc.id) return deny('owner_mismatch', { rule: present(rule) });nnew file mode 100644@@@ -0,0 +1,153 @@++'use strict';++/**++ * Consumer convergence (roadmap Wave 10 exit): when a membership changes — Billing emits++ * billing.entitlement.changed, VIP applies it and emits vip.membership.changed — every product's++ * cache (openvibe-vip/client createVipCache, the cache Chat, Community and Blog put in front of VIP)++ * stops granting within a bounded time:++ *++ * event handed to the product's cache at once (handleEvent drops the pair)++ * VIP has the event, the product does not ≤ ttlMs (the product's cache lifetime for a "yes")++ * every event lost, VIP included ≤ VIP_PROJECTION_MAX_AGE_MS + ttlMs (VIP re-asks Billing++ * once its projection is past valid_until)++ * the paid period ends (cancel at period end) never past expires_at (a "yes" is not cached beyond it)++ * VIP unreachable ≤ ttlMs, then every answer is a denial (fail closed)++ *++ * Real VIP (stub Network and Billing, one injected clock shared by VIP, Billing and the cache).++ */++const assert = require('assert');++const { boot, harness } = require('./helpers/app');++const { createVipClient, createVipCache } = require('../client/vip-client');++++const { test, run } = harness('convergence');++const TTL = 30_000;++++(async () => {++ const t = await boot();++ const creator = t.network.newUser('cora', { role: 'streamer' });++ await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Crew', publish: true } });++ const tokenClient = { authHeaders: async () => ({ Authorization: `Bearer ${t.network.signService({ sub: 'svc:community', cap: ['vip.entitlement.check', 'vip.resource.policy.evaluate'] })}` }) };++ let vipDown = false;++ const fetchVia = (url, init) => (vipDown ? Promise.reject(new Error('connect ECONNREFUSED')) : fetch(url, init));++ const vip = createVipClient({ baseUrl: t.base, tokenClient, fetch: fetchVia });++ const newCache = () => createVipCache({ vip, ttlMs: TTL, denyTtlMs: 10_000, unavailableTtlMs: 2_000, now: t.clock.now });++ const space = { service: 'community', type: 'space', id: 'crew' };++ const fallback = { requirement: 'member', binding: 'community:members_only' };++ const grants = async (cache, m) => {++ const e = await cache.entitlement({ subject: m.subject, creator: creator.subject, product: 'chat' });++ const d = await cache.evaluate({ subject: m.subject, resource: space, owner: creator.subject, fallback });++ assert.strictEqual(e.active, d.allow, `entitlement and evaluate agree (${e.status} / ${d.reason})`);++ return d.allow;++ };++ const lastChanged = (m) => t.outboxEvents('vip.membership.changed').filter((e) => e.payload.member.id === m.subject).pop();++++ test('VIP emits vip.membership.changed; a cache given the event stops granting at once', async () => {++ const m = t.network.newUser('m1');++ const cache = newCache();++ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);++ assert.strictEqual(await grants(cache, m), true);++ await t.deliverAll(t.billing.refund(m.subject, creator.subject).events);++ const ev = lastChanged(m);++ assert.strictEqual(ev.payload.active, false);++ assert.strictEqual(await grants(cache, m), true, 'still the cached yes (the event has not reached the product)');++ assert.strictEqual(cache.handleEvent(ev), true);++ assert.strictEqual(await grants(cache, m), false, 'no clock movement needed');++ });++++ test('billing.entitlement.changed straight from Billing converges the cache too', async () => {++ const m = t.network.newUser('m2');++ const cache = newCache();++ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);++ assert.strictEqual(await grants(cache, m), true);++ const refunded = t.billing.refund(m.subject, creator.subject).events;++ await t.deliverAll(refunded);++ const changed = refunded.find((e) => e.event_type === 'billing.entitlement.changed');++ assert.strictEqual(cache.handleEvent({ event: changed, seq: 7 }), true, 'a raw Events delivery body is understood');++ assert.strictEqual(await grants(cache, m), false);++ });++++ test('VIP has the change, the product does not: the cached yes ends within ttlMs', async () => {++ const m = t.network.newUser('m3');++ const cache = newCache();++ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);++ assert.strictEqual(await grants(cache, m), true);++ await t.deliverAll(t.billing.refund(m.subject, creator.subject).events);++ t.clock.advance(TTL - 1);++ assert.strictEqual(await grants(cache, m), true, 'inside the bound the product may still grant');++ t.clock.advance(2);++ assert.strictEqual(await grants(cache, m), false, 'past ttlMs it has asked VIP again');++ });++++ test('every event lost: VIP re-asks Billing past valid_until, the product follows within ttlMs', async () => {++ const m = t.network.newUser('m4');++ const cache = newCache();++ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);++ assert.strictEqual(await grants(cache, m), true);++ t.billing.refund(m.subject, creator.subject); // events dropped on the floor++ t.clock.advance(TTL + 1);++ assert.strictEqual(await grants(cache, m), true, 'VIP\'s projection is still fresh and says yes');++ t.clock.advance(t.config.projection.maxAgeMs + TTL);++ assert.strictEqual(await grants(cache, m), false, 'bound: VIP_PROJECTION_MAX_AGE_MS + ttlMs');++ });++++ test('cancel at period end: the member keeps access to the end of the paid period, not a moment past it', async () => {++ const m = t.network.newUser('m5');++ const cache = newCache();++ const { sub, events } = t.billing.pay(m.subject, creator.subject);++ await t.deliverAll(events);++ await t.deliverAll(t.billing.cancel(sub.id).events);++ const end = Date.parse(sub.current_period_end);++ t.clock.t = end - 10_000; // keep VIP's projection answering++ await t.deliverAll(t.billing.cancel(sub.id).events);++ assert.strictEqual(await grants(cache, m), true);++ t.clock.t = end + 1;++ assert.strictEqual(await grants(cache, m), false, 'the cached yes was capped at expires_at');++ t.clock.t = Date.now();++ });++++ test('VIP unreachable: the cached yes is not extended; after ttlMs every answer is a denial', async () => {++ const m = t.network.newUser('m6');++ const cache = newCache();++ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);++ assert.strictEqual(await grants(cache, m), true);++ vipDown = true;++ try {++ t.clock.advance(TTL + 1);++ const e = await cache.entitlement({ subject: m.subject, creator: creator.subject, product: 'chat' });++ assert.deepStrictEqual([e.active, e.reason], [false, 'vip_unavailable']);++ const d = await cache.evaluate({ subject: m.subject, resource: space, owner: creator.subject, fallback });++ assert.deepStrictEqual([d.allow, d.reason], [false, 'vip_unavailable']);++ assert.strictEqual(cache.peekEntitlement({ subject: m.subject, creator: creator.subject, product: 'chat' }).active, false);++ } finally { vipDown = false; }++ t.clock.advance(2_001);++ assert.strictEqual(await grants(cache, m), true, 'a failure is cached only briefly');++ });++++ test('an answer fetched while an invalidation arrives is not stored', async () => {++ const m = t.network.newUser('m7');++ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);++ let release;++ const gate = new Promise((r) => { release = r; });++ const slow = createVipClient({ baseUrl: t.base, tokenClient, fetch: async (u, i) => { const r = await fetch(u, i); await gate; return r; } });++ const cache = createVipCache({ vip: slow, ttlMs: TTL, now: t.clock.now });++ const pending = cache.entitlement({ subject: m.subject, creator: creator.subject });++ await new Promise((r) => setTimeout(r, 50));++ cache.handleEvent({ event_type: 'vip.membership.changed', payload: { member: { type: 'user', id: m.subject }, creator: { type: 'user', id: creator.subject } } });++ release();++ assert.strictEqual((await pending).active, true, 'the caller gets the answer it asked for');++ assert.strictEqual(cache.size, 0, 'but the cache does not keep an answer older than the invalidation');++ });++++ test('peekEntitlement never waits: a miss answers undefined and warms the cache', async () => {++ const m = t.network.newUser('m8');++ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);++ const cache = newCache();++ const args = { subject: m.subject, creator: creator.subject, product: 'chat' };++ assert.strictEqual(cache.peekEntitlement(args), undefined);++ await cache.entitlement(args); // joins the in-flight fetch++ assert.strictEqual(cache.peekEntitlement(args).active, true);++ assert.strictEqual(cache.handleEvent({ event_type: 'something.else', payload: {} }), false);++ assert.deepStrictEqual(cache.bounds, { grantMs: TTL, denyMs: 10_000, unavailableMs: 2_000 });++ });++++ await run().finally(() => t.close());++})();@@@ -151,6 +151,61 @@ const { test, run } = harness('policies');assert.strictEqual(spy.status, 403);});++ test('the product\'s default gate (fallback): members of the owner pass when the owner has no rule; the owner\'s rule wins', async () => {++ const res = { service: 'community', type: 'space', id: 'crew-lounge' };++ const fb = { requirement: 'member' };++ let d = await evaluate(member.subject, res, { fallback: fb });++ assert.deepStrictEqual([d.allow, d.reason, d.fallback, d.rule], [true, 'member', true, null]);++ d = await evaluate(stranger.subject, res, { fallback: fb });++ assert.deepStrictEqual([d.allow, d.reason], [false, 'not_a_member']);++ d = await evaluate(null, res, { fallback: fb });++ assert.deepStrictEqual([d.allow, d.reason], [false, 'not_signed_in']);++ d = await evaluate(creator.subject, res, { fallback: fb });++ assert.deepStrictEqual([d.allow, d.reason], [true, 'owner']);++ assert.strictEqual((await evaluate(member.subject, res)).reason, 'no_rule', 'without the fallback nothing changed');++ // The owner's own rule decides once it exists: a plan rule for another plan refuses.++ const other = (await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Other tier', publish: false } })).json.plan;++ const r = await t.call('POST', '/api/v1/policies', { user: creator, body: { resource: res, requirement: 'plan', plan_id: other.id } });++ assert.strictEqual(r.status, 201, r.text);++ d = await evaluate(member.subject, res, { fallback: fb });++ assert.deepStrictEqual([d.allow, d.reason], [false, 'plan_required']);++ await t.call('DELETE', `/api/v1/policies/${r.json.rule.id}`, { user: creator });++ assert.strictEqual((await evaluate(member.subject, res, { fallback: fb })).allow, true);++ // Owner required; a malformed fallback denies; the fallback never applies to someone else's owner.++ assert.strictEqual((await t.call('POST', '/api/v1/policies/evaluate', { ...blog, body: { subject: member.subject, resource: res, fallback: fb } })).json.reason, 'owner_required');++ assert.strictEqual((await evaluate(member.subject, res, { fallback: { requirement: 'anyone' } })).reason, 'invalid_fallback');++ const nobody = t.network.newUser('nob', { role: 'streamer' });++ d = (await t.call('POST', '/api/v1/policies/evaluate', { ...blog, body: { subject: member.subject, resource: res, owner: nobody.subject, fallback: fb } })).json;++ assert.deepStrictEqual([d.allow, d.reason], [false, 'not_a_member'], 'a creator VIP has never seen: Billing is asked, and says no');++ });++++ test('fallback with a product binding: when the owner defines a perk bound to it, the member\'s version must include it', async () => {++ const res = { service: 'blog', type: 'post', id: 'fallback-bound' };++ const fb = { requirement: 'member', binding: 'blog:gated_post' };++ // erin's `backstage` perk carries blog gated_post: v1 (oldMember) has it, v2 (member) does not.++ assert.strictEqual((await evaluate(oldMember.subject, res, { fallback: fb })).allow, true);++ const d = await evaluate(member.subject, res, { fallback: fb });++ assert.deepStrictEqual([d.allow, d.reason], [false, 'perk_missing']);++ // A binding no perk of the owner carries: any active member.++ assert.strictEqual((await evaluate(member.subject, res, { fallback: { requirement: 'member', binding: 'community:members_only' } })).allow, true);++ assert.strictEqual((await evaluate(member.subject, res, { fallback: { requirement: 'member', binding: 'nope' } })).reason, 'invalid_fallback');++ });++++ test('entitlements/check with product: the version\'s perks bound to that product, and the badge preference', async () => {++ const svc = { cap: ['vip.entitlement.check'] };++ const ask = async (who, product) => (await t.call('POST', '/api/v1/entitlements/check', { ...svc, body: { subject: who.subject, creator: creator.subject, product } })).json;++ let e = await ask(oldMember, 'blog');++ assert.strictEqual(e.active, true);++ assert.deepStrictEqual(e.product_perks.map((p) => [p.key, p.bindings.map((b) => b.binding)]), [['backstage', ['gated_post']]]);++ assert.deepStrictEqual(e.preferences, { show_badge: true, listed: false });++ e = await ask(member, 'blog');++ assert.deepStrictEqual(e.product_perks, [], 'v2 dropped the perk');++ e = await ask(stranger, 'blog');++ assert.deepStrictEqual([e.active, e.product_perks], [false, []]);++ assert.strictEqual((await ask(oldMember)).product_perks, undefined, 'no product, no perks');++ assert.strictEqual((await t.call('POST', '/api/v1/entitlements/check', { ...svc, body: { subject: member.subject, creator: creator.subject, product: 'Bad Product' } })).status, 422);++ });++test('the consumer client: allows members, and fails closed on every failure', async () => {const res = { service: 'blog', type: 'post', id: 'mine' };const tokenClient = { authHeaders: async () => ({ Authorization: `Bearer ${t.network.signService({ sub: 'svc:blog', cap: ['vip.resource.policy.evaluate', 'vip.entitlement.check'] })}` }) };
@@ -62,7 +62,8 @@ home that is not a cache.
@@ -62,7 +62,8 @@ home that is not a cache.
- **OpenVibe.Events** — delivery of Billing's events to `/internal/events`; relay of VIP's outbox.
- **OpenVibe.Events** — delivery of Billing's events to `/internal/events`; relay of VIP's outbox.
- **openvibe-contracts** v0.19.0, **openvibe-sdk** v0.4.0 (outbox, inbox, delivery signatures),
- **openvibe-contracts** v0.19.0, **openvibe-sdk** v0.4.0 (outbox, inbox, delivery signatures),
**openvibe-shared** v1.3.0 (chrome, legal pages, release, metrics, readiness).
**openvibe-shared** v1.3.0 (chrome, legal pages, release, metrics, readiness).
-- Consumers (not wired yet): Live, Chat, Community, Blog, Wiki — through the client below.
+- Consumers, through the client below: Chat (the member badge), Community (members-only spaces and
+ threads) and Blog (members-only posts); Live and Wiki are not wired yet.
- **Not** OpenVibe.Live: VIP works with Live unavailable (nothing here calls Live).
- **Not** OpenVibe.Live: VIP works with Live unavailable (nothing here calls Live).
## Run it
## Run it
@@ -71,7 +72,7 @@ home that is not a cache.
@@ -71,7 +72,7 @@ home that is not a cache.
fnm exec --using=22.22.1 npm install
fnm exec --using=22.22.1 npm install
cp .env.example .env # OV_OAUTH_CLIENT_SECRET, VIP_FORM_SECRET, VIP_EVENTS_SECRET, BILLING_URL …
cp .env.example .env # OV_OAUTH_CLIENT_SECRET, VIP_FORM_SECRET, VIP_EVENTS_SECRET, BILLING_URL …
fnm exec --using=22.22.1 npm run dev # http://localhost:4620
fnm exec --using=22.22.1 npm run dev # http://localhost:4620
-fnm exec --using=22.22.1 npm test # 5 test files: stub Network + Billing, temp DBs, random ports, injected clock
+fnm exec --using=22.22.1 npm test # 7 test files: stub Network + Billing, temp DBs, random ports, injected clock
node scripts/subscribe.js # create the three Events subscriptions for Billing's events
node scripts/subscribe.js # create the three Events subscriptions for Billing's events
node scripts/import-live.js --live-db <live snapshot> [--billing-db <billing snapshot>] [--dry-run] [--json]
node scripts/import-live.js --live-db <live snapshot> [--billing-db <billing snapshot>] [--dry-run] [--json]
```
```
@@ -168,15 +169,24 @@ handle or `network`.
@@ -168,15 +169,24 @@ handle or `network`.
| `GET /memberships/:subject[?mode=]` | `vip.membership.status` | self | memberships, version bought under, entitlement, preferences |
| `GET /memberships/:subject[?mode=]` | `vip.membership.status` | self | memberships, version bought under, entitlement, preferences |
| `POST /memberships/:creator/cancel` | — | self only | cancel at period end through Billing |
| `POST /memberships/:creator/cancel` | — | self only | cancel at period end through Billing |
| `PUT /memberships/:creator/preferences` | — | self only | `{ show_badge, listed }` |
| `PUT /memberships/:creator/preferences` | — | self only | `{ show_badge, listed }` |
-| `GET\|POST /entitlements/check` `{subject, creator, mode}` | `vip.entitlement.check` | self | `{ status: active\|inactive\|unknown, active, expires_at, cancel_at_period_end, source, stale, valid_until, membership }` |
+| `GET\|POST /entitlements/check` `{subject, creator, mode, product?}` | `vip.entitlement.check` | self | `{ status: active\|inactive\|unknown, active, expires_at, cancel_at_period_end, source, stale, valid_until, membership }`; with `product` also `product_perks` (the member's version perks bound to that product, with each binding's config, e.g. Chat's `badge`) and `preferences` (`show_badge`) |
| `GET /policies?service=&type=&id=&owner=` / `?creator=` (services must name `owner`; a person defaults to themselves) | `vip.resource.policy.get` | owner | the rule for a resource / a creator's rules |
| `GET /policies?service=&type=&id=&owner=` / `?creator=` (services must name `owner`; a person defaults to themselves) | `vip.resource.policy.get` | owner | the rule for a resource / a creator's rules |
| `POST /policies`, `DELETE /policies/:id` | `vip.resource.policy.set` | owner | gate / un-gate a resource `{resource, requirement: member\|plan\|perk, plan_id, perk_key, sensitive}` |
| `POST /policies`, `DELETE /policies/:id` | `vip.resource.policy.set` | owner | gate / un-gate a resource `{resource, requirement: member\|plan\|perk, plan_id, perk_key, sensitive}` |
-| `POST /policies/evaluate` `{subject, resource, owner, rule_id?, mode?}` — `owner` = the creator the PRODUCT says owns the resource (usr_ id or `network`); only that creator's rule applies, none → `owner_required` | `vip.resource.policy.evaluate` | self | `{ allow, reason, rule, entitlement }` — **fails closed** |
+| `POST /policies/evaluate` `{subject, resource, owner, rule_id?, mode?, fallback?}` — `owner` = the creator the PRODUCT says owns the resource (usr_ id or `network`); only that creator's rule applies, none → `owner_required`. `fallback` `{ requirement: 'member', binding?: 'product:binding' }` = the product's default gate, used only when the owner has no rule (below) | `vip.resource.policy.evaluate` | self | `{ allow, reason, rule, entitlement, fallback? }` — **fails closed** |
| `GET /creators/:ref/members` | `vip.creator.members.list` | owner / staff | active members with their plan version; from Billing, or the labelled projection when Billing is down |
| `GET /creators/:ref/members` | `vip.creator.members.list` | owner / staff | active members with their plan version; from Billing, or the labelled projection when Billing is down |
Evaluate reasons: `member`, `owner`, `no_rule`, `rule_mismatch`, `rule_disabled`, `invalid_resource`,
Evaluate reasons: `member`, `owner`, `no_rule`, `rule_mismatch`, `rule_disabled`, `invalid_resource`,
-`creator_unavailable`, `not_signed_in`, `not_a_member`, `entitlement_unknown`, `plan_required`,
-`perk_missing`. Only `allow: true` allows.
+`invalid_fallback`, `creator_unavailable`, `not_signed_in`, `not_a_member`, `entitlement_unknown`,
+`plan_required`, `perk_missing`. Only `allow: true` allows.
+
+**The product's default gate (`fallback`).** A product that marks its own resource members-only (a Blog
+post, a Community space or thread) does not register a VIP rule per resource. It asks with
+`fallback: { requirement: 'member', binding: 'blog:gated_post' }`, which applies only when the owner
+has no active rule for that resource — a rule the creator sets in VIP (plan or perk requirement,
+sensitive) always wins. Under the fallback the viewer needs an active entitlement to the owner and,
+when the owner defines an active perk carrying that product binding, a plan version that includes one
+of those perks (`perk_missing` otherwise). A creator VIP has never seen is asked of Billing as is.
+The answer has `fallback: true` and `rule: null`.
## Consumer seam: `openvibe-vip/client`
## Consumer seam: `openvibe-vip/client`
@@ -198,7 +208,39 @@ await vip.checkEntitlement({ subject, creator, mode: 'authoritative' }); // sens
@@ -198,7 +208,39 @@ await vip.checkEntitlement({ subject, creator, mode: 'authoritative' }); // sens
```
```
Until the SDK grows a `vip` module, consumers pin this repository's tag tarball and require only
Until the SDK grows a `vip` module, consumers pin this repository's tag tarball and require only
-`openvibe-vip/client` (it loads nothing else); moving it into openvibe-sdk is the intended home.
+`openvibe-vip/client` (it loads nothing else), or vendor the file verbatim with the commit it was
+copied from (Chat, Community and Blog do, in `server/vip/vip-client.js`, until a VIP tag is published);
+moving it into openvibe-sdk is the intended home.
+
+### The product cache and the convergence bound
+
+Every consumer puts `createVipCache` (same module) in front of the client, so the rule for how long
+a product may keep granting after a membership ends is written once:
+
+```js
+const { createVipClient, createVipCache } = require('openvibe-vip/client');
+const cache = createVipCache({ vip, ttlMs: 30_000, denyTtlMs: 10_000, unavailableTtlMs: 2_000 });
+await cache.evaluate({ subject, resource, owner, fallback }); // Community, Blog
+await cache.entitlement({ subject, creator, product: 'chat' }); // Chat's badge
+cache.peekEntitlement({ subject, creator, product: 'chat' }); // never waits (a miss warms it)
+cache.handleEvent(envelope); // vip.membership.changed, billing.entitlement.changed, …
+```
+
+When a membership changes (Billing emits `billing.entitlement.changed`, VIP applies it and emits
+`vip.membership.changed`), a product stops granting within:
+
+| Situation | Bound |
+|---|---|
+| the product hands the event to `cache.handleEvent` | at once (the member–creator pair is dropped; an answer in flight is not stored) |
+| VIP has the event, the product does not | `ttlMs` (a cached "yes" lives at most that long) |
+| every event lost, VIP's included | `VIP_PROJECTION_MAX_AGE_MS` + `ttlMs` (15 min + the product's TTL by default); with Billing down as well, add `VIP_PROJECTION_GRACE_MS`, after which VIP answers `unknown` |
+| cancel at period end / the period ends | never past `expires_at`: a "yes" is not cached beyond the entitlement's `expires_at` or VIP's `valid_until` |
+| VIP unreachable | `ttlMs`, then every answer is a denial (a failure never extends a "yes") |
+
+A "no" is cached `denyTtlMs` (a new member waits at most that long); failures `unavailableTtlMs`.
+[test/convergence.test.js](test/convergence.test.js) proves every row against the real VIP with an
+injected clock. The products' TTLs: Chat 60 s (a badge), Community 30 s, Blog 30 s — each product's
+README repeats its bound and its own test drives the product through a change.
Live's badge mapping (`live chat_badge`, `live powerchat_overlay`, `live ai_context`) and Chat's
Live's badge mapping (`live chat_badge`, `live powerchat_overlay`, `live ai_context`) and Chat's
(`chat badge`) are declared as product bindings of the network perk `subscriber-badge`.
(`chat badge`) are declared as product bindings of the network perk `subscriber-badge`.
@@ -10,7 +10,8 @@
@@ -10,7 +10,8 @@
"plans, immutable plan versions, perks, product bindings, member preferences and gated-resource rules in VIP's own SQLite",
"plans, immutable plan versions, perks, product bindings, member preferences and gated-resource rules in VIP's own SQLite",
"entitlement projection fed by Billing events (signed webhook + openvibe-sdk inbox) with valid_until, grace and a direct Billing fallback; 'unknown' never authorizes",
"entitlement projection fed by Billing events (signed webhook + openvibe-sdk inbox) with valid_until, grace and a direct Billing fallback; 'unknown' never authorizes",
"checkout hand-off and cancellation through OpenVibe.Billing; the membership is filed under the plan version chosen",
"checkout hand-off and cancellation through OpenVibe.Billing; the membership is filed under the plan version chosen",
- "API /api/v1 for service and user tokens; consumer client openvibe-vip/client that fails closed",
+ "API /api/v1 for service and user tokens; consumer client openvibe-vip/client that fails closed, with createVipCache (the product cache and its convergence bound, test/convergence.test.js)",
+ "the product's default gate (evaluate fallback, honouring product bindings such as blog gated_post) and entitlements/check product perks (Chat's badge); Chat, Community and Blog consume them in code (not deployed, grants pending)",
"server-rendered creator plan pages, terms history, member status and creator dashboard with the shared chrome",
"server-rendered creator plan pages, terms history, member status and creator dashboard with the shared chrome",
"outbox events vip.plan.published and vip.membership.changed (openvibe-sdk)",
"outbox events vip.plan.published and vip.membership.changed (openvibe-sdk)",
"import of Live's subscription offering, memberships from Billing entitlements",
"import of Live's subscription offering, memberships from Billing entitlements",
@@ -21,7 +22,7 @@
@@ -21,7 +22,7 @@
"not launched: openvibe.vip is still an OpenVibe.Sites placeholder and the service answers on loopback only",
"not launched: openvibe.vip is still an OpenVibe.Sites placeholder and the service answers on loopback only",
"checkout is closed until the Billing cutover (Billing runs in shadow; the owner must re-point the PowerChat webhook first); no billing.* event has reached VIP",
"checkout is closed until the Billing cutover (Billing runs in shadow; the owner must re-point the PowerChat webhook first); no billing.* event has reached VIP",
"the Live subscription import (scripts/import-live.js) has not been run (vip_migration_maps 0)",
"the Live subscription import (scripts/import-live.js) has not been run (vip_migration_maps 0)",
- "Live, Chat, Community, Blog and Wiki do not call VIP yet (no client, no vip.* grants for them)",
+ "Chat, Community and Blog call VIP in code only: not deployed, and the Network has no vip.* grants for them yet; Live and Wiki do not call VIP",
"membership badge, card and widget endpoints",
"membership badge, card and widget endpoints",
"Billing sells one channel subscription per member and creator: one purchasable plan per creator, no tiers, no network membership product"
"Billing sells one channel subscription per member and creator: one purchasable plan per creator, no tiers, no network membership product"
],
],
@@ -9,16 +9,19 @@
@@ -9,16 +9,19 @@
* audience: 'openvibe.vip', scope: 'vip.resource.policy.evaluate vip.entitlement.check' });
* audience: 'openvibe.vip', scope: 'vip.resource.policy.evaluate vip.entitlement.check' });
* const vip = createVipClient({ baseUrl: 'http://127.0.0.1:4620', tokenClient: tokens });
* 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 });
+ * 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
* if (!d.allow) return renderTeaser(d.reason); // FAILS CLOSED
*
*
- * const e = await vip.checkEntitlement({ subject: 'usr_…', creator: 'usr_…' }); // e.active
+ * 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
* await vip.isMember('usr_…', 'usr_…'); // boolean, false on any doubt
*
*
* Every failure — VIP unreachable, a timeout, a refused token, a malformed answer — is a denial
* 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
* ({ 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
* exception and never a "yes". Pass mode: 'authoritative' for sensitive content: VIP then asks
* OpenVibe.Billing directly instead of its projection.
* 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 } = {}) {
function createVipClient({ baseUrl = 'http://127.0.0.1:4620', tokenClient = null, getToken = null, fetch: fetchImpl = globalThis.fetch, timeoutMs = 2000, log = null } = {}) {
@@ -46,9 +49,9 @@ function createVipClient({ baseUrl = 'http://127.0.0.1:4620', tokenClient = null
@@ -46,9 +49,9 @@ function createVipClient({ baseUrl = 'http://127.0.0.1:4620', tokenClient = null
const note = (what, err) => { if (log && log.warn) log.warn(`[vip-client] ${what} failed closed: ${err.message}`); };
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`? */
/** 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 } = {}) {
+ async function evaluate({ subject = null, resource, owner = null, ruleId, mode, fallback } = {}) {
try {
try {
- const d = await post('/api/v1/policies/evaluate', { subject: subject || null, resource, owner, rule_id: ruleId, mode });
+ 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') };
return { ...d, allow: d.allow === true, reason: d.reason || (d.allow === true ? 'member' : 'denied') };
} catch (err) {
} catch (err) {
note('evaluate', err);
note('evaluate', err);
@@ -57,9 +60,9 @@ function createVipClient({ baseUrl = 'http://127.0.0.1:4620', tokenClient = null
@@ -57,9 +60,9 @@ function createVipClient({ baseUrl = 'http://127.0.0.1:4620', tokenClient = null
}
}
/** The entitlement of `subject` to `creator` (usr_ ids or SubjectRefs). */
/** The entitlement of `subject` to `creator` (usr_ ids or SubjectRefs). */
- async function checkEntitlement({ subject, creator, mode } = {}) {
+ async function checkEntitlement({ subject, creator, mode, product } = {}) {
try {
try {
- const e = await post('/api/v1/entitlements/check', { subject, creator, mode });
+ const e = await post('/api/v1/entitlements/check', { subject, creator, mode, product });
return { ...e, active: e.active === true && e.status === 'active' };
return { ...e, active: e.active === true && e.status === 'active' };
} catch (err) {
} catch (err) {
note('checkEntitlement', err);
note('checkEntitlement', err);
@@ -72,4 +75,159 @@ function createVipClient({ baseUrl = 'http://127.0.0.1:4620', tokenClient = null
@@ -72,4 +75,159 @@ function createVipClient({ baseUrl = 'http://127.0.0.1:4620', tokenClient = null
return { evaluate, checkEntitlement, isMember };
return { evaluate, checkEntitlement, isMember };
}
}
-module.exports = { createVipClient };
+/**
+ * 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 };
@@ -253,7 +253,18 @@ function v1Router({ domain, apiAuth }) {
@@ -253,7 +253,18 @@ function v1Router({ domain, apiAuth }) {
creatorSubject = c.subject;
creatorSubject = c.subject;
}
}
const mode = ['projection', 'authoritative'].includes(src.mode) ? src.mode : 'auto';
const mode = ['projection', 'authoritative'].includes(src.mode) ? src.mode : 'auto';
- res.json(await entitlements.check(member, creatorSubject, { mode, traceparent: trace(req) }));
+ const out = await entitlements.check(member, creatorSubject, { mode, traceparent: trace(req) });
+ // `product`: what this membership means in one product — the perks of the member's plan version
+ // with that product's bindings (a chat badge, a gated post, …) and the member's badge preference.
+ if (src.product != null && src.product !== '') {
+ const product = String(src.product);
+ if (!/^[a-z][a-z0-9-]{1,39}$/.test(product)) fail(422, 'vip.invalid_input', 'product must be a service id such as chat or blog');
+ out.product = product;
+ out.product_perks = out.active ? perks.forMembership(out.membership, product) : [];
+ const c = creators.bySubject(creatorSubject);
+ out.preferences = c ? memberships.preferences(member, c.id) : { show_badge: true, listed: false };
+ }
+ res.json(out);
});
});
r.get('/entitlements/check', entitlementCheck);
r.get('/entitlements/check', entitlementCheck);
r.post('/entitlements/check', entitlementCheck);
r.post('/entitlements/check', entitlementCheck);
@@ -312,7 +323,7 @@ function v1Router({ domain, apiAuth }) {
@@ -312,7 +323,7 @@ function v1Router({ domain, apiAuth }) {
const s = b.subject && typeof b.subject === 'object' ? b.subject.id : b.subject;
const s = b.subject && typeof b.subject === 'object' ? b.subject.id : b.subject;
subject = s ? String(s) : null;
subject = s ? String(s) : null;
}
}
- const out = await policies.evaluate({ subject, resource: b.resource, owner: b.owner || null, ruleId: b.rule_id, mode: b.mode === 'authoritative' ? 'authoritative' : 'auto', traceparent: trace(req) });
+ const out = await policies.evaluate({ subject, resource: b.resource, owner: b.owner || null, ruleId: b.rule_id, mode: b.mode === 'authoritative' ? 'authoritative' : 'auto', fallback: b.fallback || null, traceparent: trace(req) });
res.json(out);
res.json(out);
}));
}));
@@ -102,6 +102,24 @@ function createPerks({ db, now }) {
@@ -102,6 +102,24 @@ function createPerks({ db, now }) {
const bindingsOf = (perkId) => db.prepare("SELECT * FROM vip_product_bindings WHERE perk_id = ? AND status = 'active' ORDER BY product, binding").all(perkId);
const bindingsOf = (perkId) => db.prepare("SELECT * FROM vip_product_bindings WHERE perk_id = ? AND status = 'active' ORDER BY product, binding").all(perkId);
+ /**
+ * The perks a presented membership's plan version grants, with their active bindings for one
+ * product: [{ key, name, kind, scope, bindings: [{ binding, config }] }] (perks without such a
+ * binding are left out). The version's snapshot names the keys; a key resolves to the creator's
+ * own perk first, then the network's.
+ */
+ function forMembership(membership, product) {
+ if (!membership || !Array.isArray(membership.perks) || !membership.perks.length) return [];
+ const out = [];
+ for (const key of membership.perks) {
+ const p = byKey(membership.creator_id, key) || byKey('network', key);
+ if (!p || p.status !== 'active') continue;
+ const bindings = bindingsOf(p.id).filter((b) => b.product === product).map((b) => ({ binding: b.binding, config: json(b.config, {}) }));
+ if (bindings.length) out.push({ key: p.key, name: p.name, kind: p.kind, scope: p.creator_id === 'network' ? 'network' : 'creator', bindings });
+ }
+ return out;
+ }
+
function present(p, { withBindings = true } = {}) {
function present(p, { withBindings = true } = {}) {
if (!p) return null;
if (!p) return null;
return {
return {
@@ -112,7 +130,7 @@ function createPerks({ db, now }) {
@@ -112,7 +130,7 @@ function createPerks({ db, now }) {
};
};
}
}
- return { byId, byKey, create, update, list, resolveForPlan, bindingsOf, present, KINDS };
+ return { byId, byKey, create, update, list, resolveForPlan, bindingsOf, forMembership, present, KINDS };
}
}
module.exports = { createPerks };
module.exports = { createPerks };
@@ -13,6 +13,14 @@
@@ -13,6 +13,14 @@
* evaluate() FAILS CLOSED: no owner, no rule, a disabled rule, a suspended creator, an entitlement Billing
* evaluate() FAILS CLOSED: no owner, no rule, a disabled rule, a suspended creator, an entitlement Billing
* cannot confirm ("unknown"), a missing perk or any error → allow: false with the reason. Rules
* cannot confirm ("unknown"), a missing perk or any error → allow: false with the reason. Rules
* marked sensitive always ask Billing directly (never the projection).
* marked sensitive always ask Billing directly (never the projection).
+ *
+ * The product's default gate (`fallback`): a product that marks its own resource members-only (a Blog
+ * post, a Community space or thread) need not register a rule for each one. It passes
+ * fallback { requirement: 'member', binding: 'product:binding' }, which applies ONLY when the owner has
+ * no active rule for the resource (the creator's own rule always wins): the viewer must hold an active
+ * entitlement to the owner, and — when the owner defines an active perk with that product binding
+ * (e.g. `blog gated_post`) — the membership's plan version must include one of those perks. Every
+ * doubt still denies; the answer carries `fallback: true` and `rule: null`.
*/
*/
const { fail, prefixedId, iso, entityRef, isUserSubject, text } = require('../util');
const { fail, prefixedId, iso, entityRef, isUserSubject, text } = require('../util');
@@ -74,18 +82,67 @@ function createPolicies({ db, now, creators, plans, perks, memberships, entitlem
@@ -74,18 +82,67 @@ function createPolicies({ db, now, creators, plans, perks, memberships, entitlem
const deny = (reason, extra = {}) => ({ allow: false, reason, ...extra });
const deny = (reason, extra = {}) => ({ allow: false, reason, ...extra });
+ /** A product's default gate → { requirement: 'member', binding: { product, binding } | null }, or null when absent. */
+ function cleanFallback(f) {
+ if (f == null || f === false) return null;
+ const v = typeof f === 'string' ? { requirement: f } : f;
+ if (!v || typeof v !== 'object' || v.requirement !== 'member') fail(422, 'vip.invalid_input', 'fallback must be { requirement: "member", binding?: "product:binding" }');
+ if (v.binding == null || v.binding === '') return { requirement: 'member', binding: null };
+ const m = /^([a-z][a-z0-9-]{1,39})[: ]([a-z][a-z0-9_.]{1,63})$/.exec(String(v.binding));
+ if (!m) fail(422, 'vip.invalid_input', 'fallback.binding must be "product:binding", such as blog:gated_post');
+ return { requirement: 'member', binding: { product: m[1], binding: m[2] } };
+ }
+
+ /** The owner's active perks (own and network) that carry a product binding: the keys a member must hold. */
+ function boundPerkKeys(creatorId, { product, binding }) {
+ return perks.list({ creatorId, includeNetwork: true })
+ .filter((p) => perks.bindingsOf(p.id).some((b) => b.product === product && b.binding === binding))
+ .map((p) => p.key);
+ }
+
+ /** evaluate() when the owner has no rule and the product passed its default gate. */
+ async function evaluateFallback({ subject, oc, fb, mode, traceparent }) {
+ const base = { rule: null, fallback: true };
+ if (oc.status !== 'active' || !oc.subject) return deny('creator_unavailable', base);
+ if (!subject) return deny('not_signed_in', base);
+ if (!isUserSubject(subject)) return deny('not_a_member', base);
+ if (subject === oc.subject) return { allow: true, reason: 'owner', ...base, entitlement: null };
+ let ent;
+ try {
+ ent = await entitlements.check(subject, oc.subject, { mode: mode === 'authoritative' ? 'authoritative' : 'auto', traceparent });
+ } catch { return deny('entitlement_unknown', base); }
+ if (ent.status === 'unknown') return deny('entitlement_unknown', { ...base, entitlement: ent });
+ if (!ent.active) return deny('not_a_member', { ...base, entitlement: ent });
+ if (fb.binding) {
+ const needed = boundPerkKeys(oc.id, fb.binding);
+ if (needed.length) {
+ const held = memberships.perkKeys(memberships.get(subject, oc.id));
+ if (!needed.some((k) => held.includes(k))) return deny('perk_missing', { ...base, entitlement: ent });
+ }
+ }
+ return { allow: true, reason: 'member', ...base, entitlement: ent };
+ }
+
/**
/**
* May `subject` see `resource`, owned (says the product) by creator `owner`? { allow, reason, rule,
* May `subject` see `resource`, owned (says the product) by creator `owner`? { allow, reason, rule,
* entitlement }. `ruleId` pins the rule the product believes applies (a mismatch denies).
* entitlement }. `ruleId` pins the rule the product believes applies (a mismatch denies).
* mode: 'auto' (default) or 'authoritative'.
* mode: 'auto' (default) or 'authoritative'.
*/
*/
- async function evaluate({ subject, resource, owner = null, ruleId = null, mode = 'auto', traceparent }) {
+ async function evaluate({ subject, resource, owner = null, ruleId = null, mode = 'auto', fallback = null, traceparent }) {
let r;
let r;
try { r = entityRef(resource); } catch { return deny('invalid_resource'); }
try { r = entityRef(resource); } catch { return deny('invalid_resource'); }
+ let fb;
+ try { fb = cleanFallback(fallback); } catch { return deny('invalid_fallback'); }
if (!owner) return deny('owner_required');
if (!owner) return deny('owner_required');
- const oc = ownerCreator(owner);
+ let oc = ownerCreator(owner);
+ // A creator VIP has never seen can still hold Billing subscriptions: the default gate asks for them.
+ if (!oc && fb && !ruleId && owner !== 'network') {
+ const s = owner && typeof owner === 'object' ? owner.id : owner;
+ if (isUserSubject(String(s || ''))) oc = { id: null, subject: String(s), status: 'active', kind: 'creator' };
+ }
if (!oc) return deny('no_rule');
if (!oc) return deny('no_rule');
- const rule = ruleId ? byId(String(ruleId)) : forResource(r, oc.id);
+ const rule = ruleId ? byId(String(ruleId)) : (oc.id ? forResource(r, oc.id) : null);
+ if (!rule && fb && !ruleId) return evaluateFallback({ subject, oc, fb, mode, traceparent });
if (!rule) return deny('no_rule');
if (!rule) return deny('no_rule');
if (ruleId && (rule.resource_service !== r.service || rule.resource_type !== r.type || rule.resource_id !== r.id)) return deny('rule_mismatch', { rule: present(rule) });
if (ruleId && (rule.resource_service !== r.service || rule.resource_type !== r.type || rule.resource_id !== r.id)) return deny('rule_mismatch', { rule: present(rule) });
if (rule.creator_id !== oc.id) return deny('owner_mismatch', { rule: present(rule) });
if (rule.creator_id !== oc.id) return deny('owner_mismatch', { rule: present(rule) });
new file mode 100644
new file mode 100644
@@ -0,0 +1,153 @@
@@ -0,0 +1,153 @@
+'use strict';
+/**
+ * Consumer convergence (roadmap Wave 10 exit): when a membership changes — Billing emits
+ * billing.entitlement.changed, VIP applies it and emits vip.membership.changed — every product's
+ * cache (openvibe-vip/client createVipCache, the cache Chat, Community and Blog put in front of VIP)
+ * stops granting within a bounded time:
+ *
+ * event handed to the product's cache at once (handleEvent drops the pair)
+ * VIP has the event, the product does not ≤ ttlMs (the product's cache lifetime for a "yes")
+ * every event lost, VIP included ≤ VIP_PROJECTION_MAX_AGE_MS + ttlMs (VIP re-asks Billing
+ * once its projection is past valid_until)
+ * the paid period ends (cancel at period end) never past expires_at (a "yes" is not cached beyond it)
+ * VIP unreachable ≤ ttlMs, then every answer is a denial (fail closed)
+ *
+ * Real VIP (stub Network and Billing, one injected clock shared by VIP, Billing and the cache).
+ */
+const assert = require('assert');
+const { boot, harness } = require('./helpers/app');
+const { createVipClient, createVipCache } = require('../client/vip-client');
+
+const { test, run } = harness('convergence');
+const TTL = 30_000;
+
+(async () => {
+ const t = await boot();
+ const creator = t.network.newUser('cora', { role: 'streamer' });
+ await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Crew', publish: true } });
+ const tokenClient = { authHeaders: async () => ({ Authorization: `Bearer ${t.network.signService({ sub: 'svc:community', cap: ['vip.entitlement.check', 'vip.resource.policy.evaluate'] })}` }) };
+ let vipDown = false;
+ const fetchVia = (url, init) => (vipDown ? Promise.reject(new Error('connect ECONNREFUSED')) : fetch(url, init));
+ const vip = createVipClient({ baseUrl: t.base, tokenClient, fetch: fetchVia });
+ const newCache = () => createVipCache({ vip, ttlMs: TTL, denyTtlMs: 10_000, unavailableTtlMs: 2_000, now: t.clock.now });
+ const space = { service: 'community', type: 'space', id: 'crew' };
+ const fallback = { requirement: 'member', binding: 'community:members_only' };
+ const grants = async (cache, m) => {
+ const e = await cache.entitlement({ subject: m.subject, creator: creator.subject, product: 'chat' });
+ const d = await cache.evaluate({ subject: m.subject, resource: space, owner: creator.subject, fallback });
+ assert.strictEqual(e.active, d.allow, `entitlement and evaluate agree (${e.status} / ${d.reason})`);
+ return d.allow;
+ };
+ const lastChanged = (m) => t.outboxEvents('vip.membership.changed').filter((e) => e.payload.member.id === m.subject).pop();
+
+ test('VIP emits vip.membership.changed; a cache given the event stops granting at once', async () => {
+ const m = t.network.newUser('m1');
+ const cache = newCache();
+ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);
+ assert.strictEqual(await grants(cache, m), true);
+ await t.deliverAll(t.billing.refund(m.subject, creator.subject).events);
+ const ev = lastChanged(m);
+ assert.strictEqual(ev.payload.active, false);
+ assert.strictEqual(await grants(cache, m), true, 'still the cached yes (the event has not reached the product)');
+ assert.strictEqual(cache.handleEvent(ev), true);
+ assert.strictEqual(await grants(cache, m), false, 'no clock movement needed');
+ });
+
+ test('billing.entitlement.changed straight from Billing converges the cache too', async () => {
+ const m = t.network.newUser('m2');
+ const cache = newCache();
+ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);
+ assert.strictEqual(await grants(cache, m), true);
+ const refunded = t.billing.refund(m.subject, creator.subject).events;
+ await t.deliverAll(refunded);
+ const changed = refunded.find((e) => e.event_type === 'billing.entitlement.changed');
+ assert.strictEqual(cache.handleEvent({ event: changed, seq: 7 }), true, 'a raw Events delivery body is understood');
+ assert.strictEqual(await grants(cache, m), false);
+ });
+
+ test('VIP has the change, the product does not: the cached yes ends within ttlMs', async () => {
+ const m = t.network.newUser('m3');
+ const cache = newCache();
+ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);
+ assert.strictEqual(await grants(cache, m), true);
+ await t.deliverAll(t.billing.refund(m.subject, creator.subject).events);
+ t.clock.advance(TTL - 1);
+ assert.strictEqual(await grants(cache, m), true, 'inside the bound the product may still grant');
+ t.clock.advance(2);
+ assert.strictEqual(await grants(cache, m), false, 'past ttlMs it has asked VIP again');
+ });
+
+ test('every event lost: VIP re-asks Billing past valid_until, the product follows within ttlMs', async () => {
+ const m = t.network.newUser('m4');
+ const cache = newCache();
+ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);
+ assert.strictEqual(await grants(cache, m), true);
+ t.billing.refund(m.subject, creator.subject); // events dropped on the floor
+ t.clock.advance(TTL + 1);
+ assert.strictEqual(await grants(cache, m), true, 'VIP\'s projection is still fresh and says yes');
+ t.clock.advance(t.config.projection.maxAgeMs + TTL);
+ assert.strictEqual(await grants(cache, m), false, 'bound: VIP_PROJECTION_MAX_AGE_MS + ttlMs');
+ });
+
+ test('cancel at period end: the member keeps access to the end of the paid period, not a moment past it', async () => {
+ const m = t.network.newUser('m5');
+ const cache = newCache();
+ const { sub, events } = t.billing.pay(m.subject, creator.subject);
+ await t.deliverAll(events);
+ await t.deliverAll(t.billing.cancel(sub.id).events);
+ const end = Date.parse(sub.current_period_end);
+ t.clock.t = end - 10_000; // keep VIP's projection answering
+ await t.deliverAll(t.billing.cancel(sub.id).events);
+ assert.strictEqual(await grants(cache, m), true);
+ t.clock.t = end + 1;
+ assert.strictEqual(await grants(cache, m), false, 'the cached yes was capped at expires_at');
+ t.clock.t = Date.now();
+ });
+
+ test('VIP unreachable: the cached yes is not extended; after ttlMs every answer is a denial', async () => {
+ const m = t.network.newUser('m6');
+ const cache = newCache();
+ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);
+ assert.strictEqual(await grants(cache, m), true);
+ vipDown = true;
+ try {
+ t.clock.advance(TTL + 1);
+ const e = await cache.entitlement({ subject: m.subject, creator: creator.subject, product: 'chat' });
+ assert.deepStrictEqual([e.active, e.reason], [false, 'vip_unavailable']);
+ const d = await cache.evaluate({ subject: m.subject, resource: space, owner: creator.subject, fallback });
+ assert.deepStrictEqual([d.allow, d.reason], [false, 'vip_unavailable']);
+ assert.strictEqual(cache.peekEntitlement({ subject: m.subject, creator: creator.subject, product: 'chat' }).active, false);
+ } finally { vipDown = false; }
+ t.clock.advance(2_001);
+ assert.strictEqual(await grants(cache, m), true, 'a failure is cached only briefly');
+ });
+
+ test('an answer fetched while an invalidation arrives is not stored', async () => {
+ const m = t.network.newUser('m7');
+ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);
+ let release;
+ const gate = new Promise((r) => { release = r; });
+ const slow = createVipClient({ baseUrl: t.base, tokenClient, fetch: async (u, i) => { const r = await fetch(u, i); await gate; return r; } });
+ const cache = createVipCache({ vip: slow, ttlMs: TTL, now: t.clock.now });
+ const pending = cache.entitlement({ subject: m.subject, creator: creator.subject });
+ await new Promise((r) => setTimeout(r, 50));
+ cache.handleEvent({ event_type: 'vip.membership.changed', payload: { member: { type: 'user', id: m.subject }, creator: { type: 'user', id: creator.subject } } });
+ release();
+ assert.strictEqual((await pending).active, true, 'the caller gets the answer it asked for');
+ assert.strictEqual(cache.size, 0, 'but the cache does not keep an answer older than the invalidation');
+ });
+
+ test('peekEntitlement never waits: a miss answers undefined and warms the cache', async () => {
+ const m = t.network.newUser('m8');
+ await t.deliverAll(t.billing.pay(m.subject, creator.subject).events);
+ const cache = newCache();
+ const args = { subject: m.subject, creator: creator.subject, product: 'chat' };
+ assert.strictEqual(cache.peekEntitlement(args), undefined);
+ await cache.entitlement(args); // joins the in-flight fetch
+ assert.strictEqual(cache.peekEntitlement(args).active, true);
+ assert.strictEqual(cache.handleEvent({ event_type: 'something.else', payload: {} }), false);
+ assert.deepStrictEqual(cache.bounds, { grantMs: TTL, denyMs: 10_000, unavailableMs: 2_000 });
+ });
+
+ await run().finally(() => t.close());
+})();
@@ -151,6 +151,61 @@ const { test, run } = harness('policies');
@@ -151,6 +151,61 @@ const { test, run } = harness('policies');
assert.strictEqual(spy.status, 403);
assert.strictEqual(spy.status, 403);
});
});
+ test('the product\'s default gate (fallback): members of the owner pass when the owner has no rule; the owner\'s rule wins', async () => {
+ const res = { service: 'community', type: 'space', id: 'crew-lounge' };
+ const fb = { requirement: 'member' };
+ let d = await evaluate(member.subject, res, { fallback: fb });
+ assert.deepStrictEqual([d.allow, d.reason, d.fallback, d.rule], [true, 'member', true, null]);
+ d = await evaluate(stranger.subject, res, { fallback: fb });
+ assert.deepStrictEqual([d.allow, d.reason], [false, 'not_a_member']);
+ d = await evaluate(null, res, { fallback: fb });
+ assert.deepStrictEqual([d.allow, d.reason], [false, 'not_signed_in']);
+ d = await evaluate(creator.subject, res, { fallback: fb });
+ assert.deepStrictEqual([d.allow, d.reason], [true, 'owner']);
+ assert.strictEqual((await evaluate(member.subject, res)).reason, 'no_rule', 'without the fallback nothing changed');
+ // The owner's own rule decides once it exists: a plan rule for another plan refuses.
+ const other = (await t.call('POST', '/api/v1/plans', { user: creator, body: { name: 'Other tier', publish: false } })).json.plan;
+ const r = await t.call('POST', '/api/v1/policies', { user: creator, body: { resource: res, requirement: 'plan', plan_id: other.id } });
+ assert.strictEqual(r.status, 201, r.text);
+ d = await evaluate(member.subject, res, { fallback: fb });
+ assert.deepStrictEqual([d.allow, d.reason], [false, 'plan_required']);
+ await t.call('DELETE', `/api/v1/policies/${r.json.rule.id}`, { user: creator });
+ assert.strictEqual((await evaluate(member.subject, res, { fallback: fb })).allow, true);
+ // Owner required; a malformed fallback denies; the fallback never applies to someone else's owner.
+ assert.strictEqual((await t.call('POST', '/api/v1/policies/evaluate', { ...blog, body: { subject: member.subject, resource: res, fallback: fb } })).json.reason, 'owner_required');
+ assert.strictEqual((await evaluate(member.subject, res, { fallback: { requirement: 'anyone' } })).reason, 'invalid_fallback');
+ const nobody = t.network.newUser('nob', { role: 'streamer' });
+ d = (await t.call('POST', '/api/v1/policies/evaluate', { ...blog, body: { subject: member.subject, resource: res, owner: nobody.subject, fallback: fb } })).json;
+ assert.deepStrictEqual([d.allow, d.reason], [false, 'not_a_member'], 'a creator VIP has never seen: Billing is asked, and says no');
+ });
+
+ test('fallback with a product binding: when the owner defines a perk bound to it, the member\'s version must include it', async () => {
+ const res = { service: 'blog', type: 'post', id: 'fallback-bound' };
+ const fb = { requirement: 'member', binding: 'blog:gated_post' };
+ // erin's `backstage` perk carries blog gated_post: v1 (oldMember) has it, v2 (member) does not.
+ assert.strictEqual((await evaluate(oldMember.subject, res, { fallback: fb })).allow, true);
+ const d = await evaluate(member.subject, res, { fallback: fb });
+ assert.deepStrictEqual([d.allow, d.reason], [false, 'perk_missing']);
+ // A binding no perk of the owner carries: any active member.
+ assert.strictEqual((await evaluate(member.subject, res, { fallback: { requirement: 'member', binding: 'community:members_only' } })).allow, true);
+ assert.strictEqual((await evaluate(member.subject, res, { fallback: { requirement: 'member', binding: 'nope' } })).reason, 'invalid_fallback');
+ });
+
+ test('entitlements/check with product: the version\'s perks bound to that product, and the badge preference', async () => {
+ const svc = { cap: ['vip.entitlement.check'] };
+ const ask = async (who, product) => (await t.call('POST', '/api/v1/entitlements/check', { ...svc, body: { subject: who.subject, creator: creator.subject, product } })).json;
+ let e = await ask(oldMember, 'blog');
+ assert.strictEqual(e.active, true);
+ assert.deepStrictEqual(e.product_perks.map((p) => [p.key, p.bindings.map((b) => b.binding)]), [['backstage', ['gated_post']]]);
+ assert.deepStrictEqual(e.preferences, { show_badge: true, listed: false });
+ e = await ask(member, 'blog');
+ assert.deepStrictEqual(e.product_perks, [], 'v2 dropped the perk');
+ e = await ask(stranger, 'blog');
+ assert.deepStrictEqual([e.active, e.product_perks], [false, []]);
+ assert.strictEqual((await ask(oldMember)).product_perks, undefined, 'no product, no perks');
+ assert.strictEqual((await t.call('POST', '/api/v1/entitlements/check', { ...svc, body: { subject: member.subject, creator: creator.subject, product: 'Bad Product' } })).status, 422);
+ });
+
test('the consumer client: allows members, and fails closed on every failure', async () => {
test('the consumer client: allows members, and fails closed on every failure', async () => {
const res = { service: 'blog', type: 'post', id: 'mine' };
const res = { service: 'blog', type: 'post', id: 'mine' };
const tokenClient = { authHeaders: async () => ({ Authorization: `Bearer ${t.network.signService({ sub: 'svc:blog', cap: ['vip.resource.policy.evaluate', 'vip.entitlement.check'] })}` }) };
const tokenClient = { authHeaders: async () => ({ Authorization: `Bearer ${t.network.signService({ sub: 'svc:blog', cap: ['vip.resource.policy.evaluate', 'vip.entitlement.check'] })}` }) };