openvibers/openvibe.vip Commits

Gated-resource rules are per creator and the product names the owner: POST /policies/evaluate takes owner and applies only that creator's rule (none → owner_required, a pinned rule of another creator → owner_mismatch); a creator who claims someone else's reference first no longer blocks the owner (was 409) or gates it to their own members

OP OpenVibers committed 23 Sept 2026 · 797b899db2 · 6 files, +71 −30


Changes

ddiff --git a/README.md b/README.md
iindex c366b7c..41190b8 100644
---- a/README.md
++++ b/README.md
@@@ -166,9 +166,9 @@ handle or `network`.
| `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 /policies?service=&type=&id=` / `?creator=` | `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` *(proposed addition)* | owner | gate / un-gate a resource `{resource, requirement: member\|plan\|perk, plan_id, perk_key, sensitive}` |
--| `POST /policies/evaluate` `{subject, resource, rule_id?, mode?}` | `vip.resource.policy.evaluate` | self | `{ allow, reason, rule, entitlement }` — **fails closed** |
++| `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** |
| `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`,
ddiff --git a/client/vip-client.js b/client/vip-client.js
iindex 7b96c3a..587a349 100644
---- a/client/vip-client.js
++++ b/client/vip-client.js
@@@ -9,7 +9,7 @@
* 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' } });
++ * const d = await vip.evaluate({ subject: viewerSubjectOrNull, resource: { service: 'blog', type: 'post', id: '42' }, owner: authorSubject });
* if (!d.allow) return renderTeaser(d.reason); // FAILS CLOSED
*
* const e = await vip.checkEntitlement({ subject: 'usr_…', creator: 'usr_…' }); // e.active
@@@ -46,9 +46,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, ruleId, mode } = {}) {
++ async function evaluate({ subject = null, resource, owner = null, ruleId, mode } = {}) {
try {
-- const d = await post('/api/v1/policies/evaluate', { subject: subject || null, resource, rule_id: ruleId, mode });
++ const d = await post('/api/v1/policies/evaluate', { subject: subject || null, resource, owner, rule_id: ruleId, mode });
return { ...d, allow: d.allow === true, reason: d.reason || (d.allow === true ? 'member' : 'denied') };
} catch (err) {
note('evaluate', err);
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 8135e92..778e211 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -264,7 +264,11 @@ function v1Router({ domain, apiAuth }) {
needAuth(p);
if (req.query.service || req.query.type || req.query.id) {
const resource = entityRef({ service: req.query.service, type: req.query.type, id: req.query.id });
-- const rule = policies.forResource(resource);
++ // Rules are per creator: name the owner (services must; a person defaults to themselves).
++ const owner = req.query.owner ? policies.ownerCreator(req.query.owner === 'network' ? 'network' : String(req.query.owner))
++ : (p.kind === 'user' ? creators.bySubject(p.subject) : null);
++ if (!req.query.owner && p.kind !== 'user') fail(422, 'vip.invalid_input', 'owner (the creator\'s usr_ id or network) is required');
++ const rule = owner ? policies.forResource(resource, owner.id) : null;
if (p.kind === 'service' && !granted(p, CAP.policyGet)) denied(CAP.policyGet);
if (!rule || (p.kind === 'user' && !mayManage(p, creators.byId(rule.creator_id), CAP.policyGet))) fail(404, 'vip.rule_not_found', 'no rule for that resource');
return res.json({ rule: policies.present(rule) });
@@@ -308,7 +312,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, 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', traceparent: trace(req) });
res.json(out);
}));
ddiff --git a/server/db.js b/server/db.js
iindex a7735fb..006bd86 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -157,8 +157,10 @@ CREATE TABLE IF NOT EXISTS vip_gated_resource_rules (
CHECK (requirement <> 'plan' OR plan_id IS NOT NULL),
CHECK (requirement <> 'perk' OR perk_key IS NOT NULL)
);
--CREATE UNIQUE INDEX IF NOT EXISTS idx_vip_rules_resource
-- ON vip_gated_resource_rules (resource_service, resource_type, resource_id) WHERE status = 'active';
++-- One active rule per (resource, creator): the product names the owner when it asks (policies.js).
++DROP INDEX IF EXISTS idx_vip_rules_resource;
++CREATE UNIQUE INDEX IF NOT EXISTS idx_vip_rules_resource_creator
++ ON vip_gated_resource_rules (resource_service, resource_type, resource_id, creator_id) WHERE status = 'active';
CREATE TABLE IF NOT EXISTS vip_migration_maps (
source TEXT NOT NULL,
ddiff --git a/server/domain/policies.js b/server/domain/policies.js
iindex e3609d2..94382d6 100644
---- a/server/domain/policies.js
++++ b/server/domain/policies.js
@@@ -5,7 +5,12 @@
* members of one plan, or for members whose plan VERSION includes perk K. A product stores the
* resource; VIP stores only the typed reference (EntityRef) and the rule.
*
-- * evaluate() FAILS CLOSED: no rule, a disabled rule, a suspended creator, an entitlement Billing
++ * A rule belongs to one creator, and several creators may each hold a rule for the same reference:
++ * VIP cannot tell who owns a product's resource, so the PRODUCT names the owner when it asks
++ * (evaluate's `owner`) and only that creator's rule applies. Otherwise anyone could claim a
++ * reference first — blocking the real owner (409) and gating it to their own members.
++ *
++ * 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).
*/
@@@ -13,8 +18,14 @@ const { fail, prefixedId, iso, entityRef, isUserSubject, text } = require('../ut
function createPolicies({ db, now, creators, plans, perks, memberships, entitlements }) {
const byId = (id) => db.prepare('SELECT * FROM vip_gated_resource_rules WHERE id = ?').get(id) || null;
-- const forResource = (r) => db.prepare(`SELECT * FROM vip_gated_resource_rules WHERE resource_service = ? AND resource_type = ? AND resource_id = ? AND status = 'active'`)
-- .get(r.service, r.type, r.id) || null;
++ const forResource = (r, creatorId) => db.prepare(`SELECT * FROM vip_gated_resource_rules WHERE resource_service = ? AND resource_type = ? AND resource_id = ? AND creator_id = ? AND status = 'active'`)
++ .get(r.service, r.type, r.id, creatorId) || null;
++ /** The creator a product names as a resource's owner: SubjectRef, usr_ id, or 'network'. */
++ function ownerCreator(owner) {
++ if (owner === 'network') return creators.network();
++ const s = owner && typeof owner === 'object' ? owner.id : owner;
++ return isUserSubject(String(s || '')) ? creators.bySubject(String(s)) : null;
++ }
/** Create or replace the active rule for a resource. */
function set({ creatorId, resource, requirement = 'member', planId = null, perkKey = null, sensitive = false, actor = null }) {
@@@ -30,8 +41,7 @@ function createPolicies({ db, now, creators, plans, perks, memberships, entitlem
perkKey = text(perkKey, 'perk_key', 48, { required: true });
if (!perks.byKey(creatorId, perkKey) && !perks.byKey('network', perkKey)) fail(422, 'vip.perk_not_found', `no perk ${perkKey} for this creator`);
} else perkKey = null;
-- const existing = forResource(r);
-- if (existing && existing.creator_id !== creatorId) fail(409, 'vip.rule_conflict', 'another creator already gates this resource');
++ const existing = forResource(r, creatorId);
const at = iso(now());
const id = prefixedId('vgr', now());
db.transaction(() => {
@@@ -65,15 +75,20 @@ function createPolicies({ db, now, creators, plans, perks, memberships, entitlem
const deny = (reason, extra = {}) => ({ allow: false, reason, ...extra });
/**
-- * May `subject` see `resource`? { allow, reason, rule, entitlement }. `ruleId` pins the rule the
-- * product believes applies (a mismatch denies). mode: 'auto' (default) or 'authoritative'.
++ * 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, ruleId = null, mode = 'auto', traceparent }) {
++ async function evaluate({ subject, resource, owner = null, ruleId = null, mode = 'auto', traceparent }) {
let r;
try { r = entityRef(resource); } catch { return deny('invalid_resource'); }
-- const rule = ruleId ? byId(String(ruleId)) : forResource(r);
++ if (!owner) return deny('owner_required');
++ const oc = ownerCreator(owner);
++ if (!oc) return deny('no_rule');
++ const rule = ruleId ? byId(String(ruleId)) : forResource(r, oc.id);
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) });
if (rule.status !== 'active') return deny('rule_disabled', { rule: present(rule) });
const creator = creators.byId(rule.creator_id);
if (!creator || creator.status !== 'active' || !creator.subject) return deny('creator_unavailable', { rule: present(rule) });
@@@ -93,7 +108,7 @@ function createPolicies({ db, now, creators, plans, perks, memberships, entitlem
return { allow: true, reason: 'member', rule: present(rule), entitlement: ent };
}
-- return { byId, forResource, set, disable, list, present, evaluate };
++ return { byId, forResource, ownerCreator, set, disable, list, present, evaluate };
}
module.exports = { createPolicies };
ddiff --git a/test/policies.test.js b/test/policies.test.js
iindex 898e948..dc6d7d7 100644
---- a/test/policies.test.js
++++ b/test/policies.test.js
@@@ -17,7 +17,7 @@ const { test, run } = harness('policies');
const stranger = t.network.newUser('hal');
const blog = { cap: ['vip.resource.policy.evaluate'], sub: 'svc:blog' };
const post = { service: 'blog', type: 'post', id: '42' };
-- const evaluate = async (subject, resource = post, extra = {}) => (await t.call('POST', '/api/v1/policies/evaluate', { ...blog, body: { subject, resource, ...extra } })).json;
++ const evaluate = async (subject, resource = post, extra = {}) => (await t.call('POST', '/api/v1/policies/evaluate', { ...blog, body: { subject, resource, owner: creator.subject, ...extra } })).json;
let plan;
await t.call('POST', '/api/v1/perks', { user: creator, body: { name: 'Backstage posts', key: 'backstage', kind: 'gated_content', bindings: [{ product: 'blog', binding: 'gated_post' }] } });
@@@ -26,6 +26,23 @@ const { test, run } = harness('policies');
await t.call('PATCH', `/api/v1/plans/${plan.id}`, { user: creator, body: { perks: [] } }); // v2 drops it
await t.deliverAll(t.billing.pay(member.subject, creator.subject).events); // joins under v2
++ test('a reference claimed first by another creator neither blocks the owner nor applies to them', async () => {
++ const squatter = t.network.newUser('sly', { role: 'streamer' });
++ const res = { service: 'blog', type: 'post', id: 'squat-1' };
++ const s1 = await t.call('POST', '/api/v1/policies', { user: squatter, body: { resource: res, requirement: 'member' } });
++ assert.strictEqual(s1.status, 201, s1.text);
++ const d0 = await evaluate(member.subject, res);
++ assert.strictEqual(d0.allow, false);
++ assert.strictEqual(d0.reason, 'no_rule', 'the squatter\'s rule is not the owner\'s');
++ const own = await t.call('POST', '/api/v1/policies', { user: creator, body: { resource: res, requirement: 'member' } });
++ assert.strictEqual(own.status, 201, 'the real owner is not blocked: ' + own.text);
++ assert.strictEqual((await evaluate(member.subject, res)).allow, true);
++ const pinned = await evaluate(member.subject, res, { rule_id: s1.json.rule.id });
++ assert.strictEqual(pinned.reason, 'owner_mismatch');
++ const noOwner = (await t.call('POST', '/api/v1/policies/evaluate', { ...blog, body: { subject: member.subject, resource: res } })).json;
++ assert.strictEqual(noOwner.reason, 'owner_required');
++ });
++
test('no rule for a resource → denied', async () => {
const d = await evaluate(member.subject);
assert.deepStrictEqual([d.allow, d.reason], [false, 'no_rule']);
@@@ -103,7 +120,7 @@ const { test, run } = harness('policies');
});
test('rule pinning, disabled rules, malformed resources and unknown rules all deny', async () => {
-- const rule = (await t.call('GET', `/api/v1/policies?service=blog&type=post&id=42`, { cap: ['vip.resource.policy.get'] })).json.rule;
++ const rule = (await t.call('GET', `/api/v1/policies?service=blog&type=post&id=42&owner=${creator.subject}`, { cap: ['vip.resource.policy.get'] })).json.rule;
assert.ok(rule && rule.id);
assert.strictEqual((await evaluate(member.subject, post, { rule_id: rule.id })).allow, true);
assert.strictEqual((await evaluate(member.subject, { service: 'blog', type: 'post', id: 'other' }, { rule_id: rule.id })).reason, 'rule_mismatch');
@@@ -115,19 +132,22 @@ const { test, run } = harness('policies');
assert.strictEqual((await evaluate(member.subject, post)).reason, 'no_rule');
});
-- test('only the creator sets rules; another creator cannot take over a gated resource', async () => {
++ test('only the creator sets rules; another creator\'s rule cannot take over a gated resource', async () => {
const res = { service: 'blog', type: 'post', id: 'mine' };
await t.call('POST', '/api/v1/policies', { user: creator, body: { resource: res } });
const other = t.network.newUser('kim', { role: 'streamer' });
++ // Another creator's rule for the same reference is theirs alone: it never applies when the
++ // product names the real owner, so it cannot take the resource over.
const r = await t.call('POST', '/api/v1/policies', { user: other, body: { resource: res } });
-- assert.strictEqual(r.status, 409);
++ assert.strictEqual(r.status, 201);
++ assert.strictEqual((await evaluate(member.subject, res)).allow, true, 'the owner\'s rule still decides');
const svcNo = await t.call('POST', '/api/v1/policies', { cap: ['vip.resource.policy.evaluate'], body: { creator: { type: 'user', id: creator.subject }, resource: res } });
assert.strictEqual(svcNo.status, 403);
const evalNo = await t.call('POST', '/api/v1/policies/evaluate', { cap: ['vip.entitlement.check'], body: { subject: member.subject, resource: res } });
assert.strictEqual(evalNo.status, 403);
-- const self = await t.call('POST', '/api/v1/policies/evaluate', { user: member, body: { resource: res } });
++ const self = await t.call('POST', '/api/v1/policies/evaluate', { user: member, body: { resource: res, owner: creator.subject } });
assert.strictEqual(self.json.allow, true);
-- const spy = await t.call('POST', '/api/v1/policies/evaluate', { user: stranger, body: { subject: member.subject, resource: res } });
++ const spy = await t.call('POST', '/api/v1/policies/evaluate', { user: stranger, body: { subject: member.subject, resource: res, owner: creator.subject } });
assert.strictEqual(spy.status, 403);
});
@@@ -135,20 +155,20 @@ const { test, run } = harness('policies');
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 vip = createVipClient({ baseUrl: t.base, tokenClient });
-- assert.strictEqual((await vip.evaluate({ subject: member.subject, resource: res })).allow, true);
-- assert.strictEqual((await vip.evaluate({ subject: stranger.subject, resource: res })).allow, false);
++ assert.strictEqual((await vip.evaluate({ subject: member.subject, resource: res, owner: creator.subject })).allow, true);
++ assert.strictEqual((await vip.evaluate({ subject: stranger.subject, resource: res, owner: creator.subject })).allow, false);
assert.strictEqual(await vip.isMember(member.subject, creator.subject), true);
assert.strictEqual(await vip.isMember(stranger.subject, creator.subject), false);
const down = createVipClient({ baseUrl: 'http://127.0.0.1:9', tokenClient, timeoutMs: 500 });
-- const d = await down.evaluate({ subject: member.subject, resource: res });
++ const d = await down.evaluate({ subject: member.subject, resource: res, owner: creator.subject });
assert.deepStrictEqual([d.allow, d.reason], [false, 'vip_unavailable']);
assert.strictEqual(await down.isMember(member.subject, creator.subject), false);
const noCap = createVipClient({ baseUrl: t.base, tokenClient: { authHeaders: async () => ({ Authorization: `Bearer ${t.network.signService({ sub: 'svc:blog', cap: [] })}` }) } });
-- assert.strictEqual((await noCap.evaluate({ subject: member.subject, resource: res })).allow, false);
++ assert.strictEqual((await noCap.evaluate({ subject: member.subject, resource: res, owner: creator.subject })).allow, false);
const badToken = createVipClient({ baseUrl: t.base, getToken: async () => 'not-a-token' });
-- assert.strictEqual((await badToken.evaluate({ subject: member.subject, resource: res })).allow, false);
++ assert.strictEqual((await badToken.evaluate({ subject: member.subject, resource: res, owner: creator.subject })).allow, false);
const tokenFails = createVipClient({ baseUrl: t.base, getToken: async () => { throw new Error('no grant'); } });
assert.strictEqual((await tokenFails.checkEntitlement({ subject: member.subject, creator: creator.subject })).status, 'unknown');
const garbage = createVipClient({ baseUrl: t.base, tokenClient, fetch: async () => new Response('{"allow":"yes"}', { status: 200 }) });