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

OP OpenVibers committed 23 Sept 2026 · 2accbebaea · 8 files, +517 −22

- POST /policies/evaluate takes fallback { requirement: 'member', binding?: 'product:binding' }: a product that marks its own resource members-only (a Blog post, a Community space or thread) needs no per-resource rule. It applies only when the owner has no active rule (the creator's rule always wins); the viewer needs an active entitlement to the owner and, when the owner defines an active perk carrying that binding (blog gated_post), a plan version including it (perk_missing otherwise). A creator VIP has never seen is asked of Billing as is. Owner still required; every doubt denies; answers carry fallback: true, rule: null. A malformed fallback denies (invalid_fallback).
- POST /entitlements/check takes product: the answer adds product_perks (the member's version perks bound to that product, with each binding's config: Chat's badge) and preferences (show_badge).
- client/vip-client.js: evaluate passes fallback, checkEntitlement passes product, and createVipCache is the cache every product puts in front of VIP. A "yes" lives at most ttlMs and never past expires_at or valid_until; a "no" denyTtlMs; a failure unavailableTtlMs and never extends a "yes"; handleEvent drops a member-creator pair on vip.membership.changed, billing.entitlement.changed, billing.subscription.canceled or billing.transaction.reversed, and an answer in flight during the invalidation is not stored; peekEntitlement never waits (Chat's send path).
- test/convergence.test.js drives the real VIP (stub Billing, one injected clock) through a change and proves each bound: event given to the cache → at once; VIP has it, the product does not → ttlMs; every event lost → VIP_PROJECTION_MAX_AGE_MS + ttlMs; cancel at period end → not past expires_at; VIP down → ttlMs, then denials. Policies tests cover the fallback, bindings and product perks.
- README: the default gate, product perks, the product cache and the bound table; STATUS.

Changes

ddiff --git a/README.md b/README.md
iindex 7f78fc0..7f5c9ae 100644
---- a/README.md
++++ b/README.md
@@@ -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 install
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 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/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`.
ddiff --git a/STATUS.json b/STATUS.json
iindex 11a5183..ad02af0 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
],
ddiff --git a/client/vip-client.js b/client/vip-client.js
iindex 587a349..453deaa 100644
---- a/client/vip-client.js
++++ b/client/vip-client.js
@@@ -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 = null
const note = (what, err) => { if (log && log.warn) log.warn(`[vip-client] ${what} failed closed: ${err.message}`); };
/** May `subject` (a usr_ id, a SubjectRef, or null for a signed-out viewer) see `resource`? */
-- async function evaluate({ subject = null, resource, owner = null, ruleId, mode } = {}) {
++ 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 = null
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 };
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 778e211..cb9d0b8 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -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);
}));
ddiff --git a/server/domain/perks.js b/server/domain/perks.js
iindex cac77b2..d19751c 100644
---- a/server/domain/perks.js
++++ b/server/domain/perks.js
@@@ -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 };
ddiff --git a/server/domain/policies.js b/server/domain/policies.js
iindex 94382d6..e259540 100644
---- a/server/domain/policies.js
++++ b/server/domain/policies.js
@@@ -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, entitlem
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,
* 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) });
ddiff --git a/test/convergence.test.js b/test/convergence.test.js
nnew file mode 100644
iindex 0000000..0f59514
---- /dev/null
++++ b/test/convergence.test.js
@@@ -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());
++})();
ddiff --git a/test/policies.test.js b/test/policies.test.js
iindex dc6d7d7..770f00b 100644
---- a/test/policies.test.js
++++ b/test/policies.test.js
@@@ -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'] })}` }) };