openvibers/openvibe.inventory Commits
Inventory 0.3.0: grantors (ADR-054 §3, contracts 0.123.0) — Live lets OpenVibe.Quest give six common items as quest rewards; item pages say how to get them (#7)
Changes
@@@ -2,6 +2,16 @@What changed in OpenVibe.Inventory, newest first. Each site also publishes its patch notes at /updates.++## 0.3.0 — 2026-10-09++++- **Grantors** (ADR-054 §3 amendment, openvibe-contracts 0.123.0): a definition may name the services or apps that may++ grant it. A grantor only grants (earned or granted, idempotent per its own key, within the cap), and the ledger and++ the `inventory.item.granted` event record it as the actor with the issuer kept. The issuer sets and clears the list++ with `PATCH /definitions/:id`; a person is never a grantor (`migrations/0002_grantors.sql`).++- Live lets OpenVibe.Quest give six common items as quest rewards: Sparkle, Hearts, Basic Cap, Fire Name, Ice Name++ and Rainbow Name (`server/data/live-grantors.json`, set at boot). Their item pages say how to get them.++- The public reads answer every origin (#4), and the sitemap and llms.txt list every item, kind and the public API (#3).++## 0.2.0 — 2026-10-09- **The inventory (ADR-054, plan T21 step 2).**@@@ -23,7 +23,11 @@ people do; people keep them in one place and wear them on every site that shows- **Equipped:** one instance per kind and slot.- **Ledger:** an append-only row for every movement, with who did it and why.--Only a kind's issuer defines and grants its items: `service:live` for `live.*`. **Nothing is sold, bought, traded or++Only a kind's issuer defines and grants its items: `service:live` for `live.*`. An issuer may name **grantors** on++one of its items (ADR-054 §3 amendment): other services or apps that may grant that item and nothing more (no edit,++no revoke), recorded in the ledger as the actor. Live names OpenVibe.Quest on six common items, so they are earned as++quest rewards ([server/data/live-grantors.json](server/data/live-grantors.json), applied at boot); their pages say++where to earn them. **Nothing is sold, bought, traded orconverted into OpenCoins or Vibes**, and there are no paid random rewards, until a later ADR covers the legal basis andBilling (ADR-054 §5). A test fails if a route or a column for that appears.@@@ -119,7 +123,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).<!-- versions:start -->--- openvibe-contracts: v0.122.1++- openvibe-contracts: v0.123.0- openvibe-sdk: v0.37.0- openvibe-shared: v2.17.0<!-- versions:end -->@@@ -6,11 +6,11 @@"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": "5030","domain": "inventory.openvibe.network",-- "contracts": "openvibe-contracts v0.122.1",++ "contracts": "openvibe-contracts v0.123.0","sdk": "openvibe-sdk v0.37.0","shared": "openvibe-shared v2.17.0","packages": [-- "openvibe-contracts v0.122.1",++ "openvibe-contracts v0.123.0","openvibe-sdk v0.37.0","openvibe-shared v2.17.0"],nnew file mode 100644@@@ -0,0 +1,5 @@++-- phase: expand++-- ADR-054 §3 amendment (Contracts 0.123.0): a definition may name grantors, other services or apps its issuer lets++-- grant that one item (OpenVibe.Quest giving some of Live's hats as quest rewards). Grant only: defining, editing,++-- consuming and revoking stay the issuer's. Additive; an older release never reads the column.++ALTER TABLE inventory_definitions ADD COLUMN IF NOT EXISTS grantors jsonb NOT NULL DEFAULT '[]'::jsonb;@@@ -1,12 +1,12 @@{"name": "openvibe-inventory",-- "version": "0.2.0",++ "version": "0.3.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-inventory",-- "version": "0.2.0",++ "version": "0.3.0","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.20.0",@@@ -16,7 +16,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.122.1",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.123.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.17.0","pg": "^8.23.0"@@@ -872,9 +872,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.122.1",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.122.1",-- "integrity": "sha512-+edJcURx0E76QWdgjr42fl0OgQxdWl7FA1OuWpopDKfO4ndidvf+GEz5skt1upSN4JLecLWrpMMyXJN27GjZsQ==",++ "version": "0.123.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.123.0",++ "integrity": "sha512-aO9o9qADB5wQ6XISPprGZ43P0eEIn/4aQmNVECe6YkDs4NFYvZD8TVPAzrmRk2tWFyIzaYYo3MTxy6ku71y6+w==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1,6 +1,6 @@{"name": "openvibe-inventory",-- "version": "0.2.0",++ "version": "0.3.0","private": true,"description": "OpenVibe.Inventory — Your items, on every OpenVibe site.","main": "server/index.js",@@@ -23,7 +23,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.122.1",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.123.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.17.0","pg": "^8.23.0"nnew file mode 100644@@@ -0,0 +1,11 @@++{++ "_about": "Live's items that other services may grant (ADR-054 §3 grantors), by Live's item id. OpenVibe.Quest gives these as quest rewards: Sparkle for saying hello in chat, Hearts for a first follow, the Basic Cap for going live, Fire Name for streaming three times, Ice Name for starting a thread, Rainbow Name for improving a wiki page.",++ "grantors": {++ "px_sparkle": ["service:quest"],++ "px_hearts": ["service:quest"],++ "hat_basic_cap": ["service:quest"],++ "fx_fire": ["service:quest"],++ "fx_ice": ["service:quest"],++ "fx_rainbow": ["service:quest"]++ }++}@@@ -149,6 +149,8 @@ ${shown.map((k) => html`<section class="inv-kind" aria-labelledby="k-${k.id.repl<li><b>${d.supply.cap == null ? 'Unlimited' : `${d.supply.cap - d.supply.issued} of ${d.supply.cap}`}</b> ${d.supply.cap == null ? 'supply' : 'left'}</li><li><b>${k.slots.length ? 'Wearable' : 'Collectible'}</b> ${k.slots.length ? `in the ${k.slots.join(', ')} slot` : 'shown on your profile'}</li></ul>++${(d.grantors || []).includes('service:quest') && d.status === 'published' ? html`<h2>How to get it</h2>++<p>Earn it on <a href="https://openvibe.quest/">OpenVibe.Quest</a>: it is the reward for one of the quests there, given once to everyone who completes it.</p>` : ''}<h2>Where it shows</h2><p>${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ').replace(/^./, (c) => c.toUpperCase())}.</p><h2>Who gives it</h2>@@@ -5,6 +5,8 @@* server/data/kinds.json the kinds (contracts with renderers); an existing kind is updated in place* server/data/live-definitions.json Live's cosmetics catalog as definitions issued by service:live, found again by their* alias (Live's item id); a definition already there is left as it is++ * server/data/live-grantors.json the services Live lets grant some of its items (ADR-054 §3), set on the definition++ * whenever the file and the database differ (Quest's quest rewards)* A boot that changes nothing writes nothing.*/const path = require('path');@@@ -23,7 +25,16 @@ async function seed(inv, { log = console } = {}) {made++;}if (made) log.log(`[OpenVibe.Inventory] seeded ${made} of Live's cosmetics as definitions`);-- return { kinds: kinds.length, definitions: made };++ const { grantors } = require(path.join(DATA, 'live-grantors.json'));++ let granting = 0;++ for (const [alias, list] of Object.entries(grantors)) {++ const d = await inv.byAlias(LIVE, alias);++ if (!d || JSON.stringify(d.grantors || []) === JSON.stringify(list)) continue;++ await inv.updateDefinition(LIVE, d.id, { grantors: list });++ granting++;++ }++ if (granting) log.log(`[OpenVibe.Inventory] set the grantors of ${granting} of Live's items`);++ return { kinds: kinds.length, definitions: made, grantors: granting };}module.exports = { seed, LIVE };@@@ -18,6 +18,8 @@ const Ajv = require('ajv/dist/2020');const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const DEF_RE = /^itd_[0-9A-HJKMNP-TV-Z]{26}$/;++// Who an issuer may name as a grantor of one of its items (ADR-054 §3): a service or an app, never a person.++const GRANTOR_RE = /^(service:[a-z][a-z0-9-]*|app:app_[0-9A-HJKMNP-TV-Z]{26})$/;const INST_RE = /^inv_[0-9A-HJKMNP-TV-Z]{26}$/;const RARITY = ['common', 'uncommon', 'rare', 'epic', 'legendary'];@@@ -44,6 +46,7 @@ function definitionRow(r, aliases = []) {};if (r.description) out.description = r.description;if (aliases.length) out.aliases = aliases;++ if (Array.isArray(r.grantors) && r.grantors.length) out.grantors = r.grantors;if (r.published_at) out.published_at = r.published_at;if (r.credit_subject || r.credit_name) out.credit = { ...(r.credit_subject ? { subject: r.credit_subject } : {}), ...(r.credit_name ? { name: r.credit_name } : {}) };return out;@@@ -141,6 +144,7 @@ function createInventory({ s, outbox, log = console }) {}if (!partial || has('rarity')) if (!RARITY.includes(b.rarity)) fail(422, 'inventory.bad_rarity', `rarity is one of ${RARITY.join(', ')}`);if (has('supply_cap') && b.supply_cap !== null && !(Number.isInteger(b.supply_cap) && b.supply_cap > 0)) fail(422, 'inventory.bad_supply', 'supply_cap is a positive whole number or null');++ if (has('grantors') && (!Array.isArray(b.grantors) || b.grantors.length > 8 || new Set(b.grantors).size !== b.grantors.length || b.grantors.some((x) => !GRANTOR_RE.test(String(x))))) fail(422, 'inventory.bad_grantors', 'grantors: up to 8 service:… or app:app_… subjects');if (has('aliases') && (!Array.isArray(b.aliases) || b.aliases.length > 8 || b.aliases.some((x) => !/^[a-z0-9][a-z0-9_.-]{0,63}$/.test(String(x))))) fail(422, 'inventory.bad_alias', 'aliases: up to 8 lower-case ids');}@@@ -156,9 +160,9 @@ function createInventory({ s, outbox, log = console }) {const now = s.iso();const st = status || (b.publish ? 'published' : 'draft');return await s.tx(async () => {-- await db.exec(`INSERT INTO inventory_definitions (id, kind, name, description, art, rarity, attributes, issuer, supply_cap, status, created_at, updated_at, published_at)-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11, $12)`,-- [id, kind.id, b.name.trim(), b.description || null, JSON.stringify(b.art), b.rarity, JSON.stringify(b.attributes || {}), requester, b.supply_cap || null, st, now, st === 'published' ? now : null]);++ await db.exec(`INSERT INTO inventory_definitions (id, kind, name, description, art, rarity, attributes, issuer, supply_cap, status, created_at, updated_at, published_at, grantors)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11, $12, $13)`,++ [id, kind.id, b.name.trim(), b.description || null, JSON.stringify(b.art), b.rarity, JSON.stringify(b.attributes || {}), requester, b.supply_cap || null, st, now, st === 'published' ? now : null, JSON.stringify(b.grantors || [])]);for (const alias of b.aliases || []) {const taken = await db.maybe('SELECT definition_id FROM inventory_definition_aliases WHERE issuer = $1 AND alias = $2', [requester, alias]);if (taken) fail(409, 'inventory.alias_taken', `${alias} already names ${taken.definition_id}`);@@@ -186,12 +190,15 @@ function createInventory({ s, outbox, log = console }) {else if (b.publish && cur.status === 'draft') status = 'published';else if (b.status !== undefined && b.status !== cur.status && b.status !== 'retired') fail(422, 'inventory.bad_status', 'status: retired, or publish: true on a draft');const now = s.iso();++ // grantors change any time, published or not: they say who may grant the item, not what it is (ADR-054 §3).++ const grantors = b.grantors !== undefined ? b.grantors : (cur.grantors || []);await db.exec(`UPDATE inventory_definitions SET name = $2, description = $3, art = $4, rarity = $5, attributes = $6, supply_cap = $7, status = $8,-- updated_at = $9, published_at = COALESCE(published_at, CASE WHEN $8 = 'published' THEN $9 END) WHERE id = $1`,++ updated_at = $9, published_at = COALESCE(published_at, CASE WHEN $8 = 'published' THEN $9 END), grantors = $10 WHERE id = $1`,[cur.id, b.name !== undefined ? b.name.trim() : cur.name, b.description !== undefined ? b.description : cur.description,JSON.stringify(b.art !== undefined ? b.art : cur.art), b.rarity !== undefined ? b.rarity : cur.rarity,-- JSON.stringify(b.attributes !== undefined ? b.attributes : cur.attributes), b.supply_cap !== undefined ? b.supply_cap : cur.supply_cap, status, now]);-- await ledger(status !== cur.status ? status : 'edited', { definition: cur.id, actor: requester });++ JSON.stringify(b.attributes !== undefined ? b.attributes : cur.attributes), b.supply_cap !== undefined ? b.supply_cap : cur.supply_cap, status, now, JSON.stringify(grantors)]);++ const grantorsChanged = b.grantors !== undefined && JSON.stringify(grantors) !== JSON.stringify(cur.grantors || []);++ await ledger(status !== cur.status ? status : 'edited', { definition: cur.id, actor: requester, ...(grantorsChanged ? { detail: { grantors } } : {}) });if (status === 'published' && cur.status !== 'published') {await emit('inventory.definition.published', { subject: { type: 'item_definition', id: cur.id }, visibility: 'public', actor: requester, payload: { definition_id: cur.id, kind: cur.kind, name: b.name !== undefined ? b.name.trim() : cur.name, rarity: b.rarity || cur.rarity, issuer: cur.issuer } });}@@@ -218,7 +225,8 @@ function createInventory({ s, outbox, log = console }) {if (prior) return { instance: instanceRow(await db.maybe('SELECT * FROM inventory_instances WHERE id = $1', [prior.instance_id])), created: false };const def = await db.maybe('SELECT * FROM inventory_definitions WHERE id = $1 FOR UPDATE', [b.definition_id]);if (!def) fail(404, 'inventory.definition_not_found', 'no such definition');-- if (def.issuer !== requester) fail(403, 'inventory.not_issuer', `only ${def.issuer} grants this item`);++ const isGrantor = Array.isArray(def.grantors) && def.grantors.includes(requester);++ if (def.issuer !== requester && !isGrantor) fail(403, 'inventory.not_issuer', `only ${def.issuer}${(def.grantors || []).length ? ' and the grantors it names' : ''} grant this item`);if (def.status !== 'published') fail(409, 'inventory.not_published', `the item is ${def.status}`);const kind = await getKind(def.kind);if (b.attributes !== undefined) await checkAttributes(kind.id, { ...def.attributes, ...b.attributes });@@@ -239,7 +247,7 @@ function createInventory({ s, outbox, log = console }) {await db.exec('INSERT INTO inventory_grants (issuer, idempotency_key, instance_id, created_at) VALUES ($1, $2, $3, $4)', [requester, key, id, now]);await ledger(origin, { owner: b.subject, instance: id, definition: def.id, actor: requester, reason: b.reason || null });await emit('inventory.item.granted', { subject: { type: 'user', id: b.subject }, visibility: 'subject', actor: requester,-- payload: { instance_id: id, definition_id: def.id, kind: def.kind, owner: b.subject, origin, issuer: requester } });++ payload: { instance_id: id, definition_id: def.id, kind: def.kind, owner: b.subject, origin, issuer: def.issuer } }); // a grantor is the event's actorreturn { instance: instanceRow(await db.maybe('SELECT * FROM inventory_instances WHERE id = $1', [id])), created: true };});}nnew file mode 100644@@@ -0,0 +1,77 @@++'use strict';++/**++ * Grantors (ADR-054 §3 amendment, Contracts 0.123.0): Live, the issuer of its hats and effects, lets OpenVibe.Quest++ * grant six of them as quest rewards (server/data/live-grantors.json, set at boot). Quest grants those (earned,++ * idempotent per its own key, recorded with Quest as the actor, the event naming Live as the issuer) and nothing else;++ * it cannot edit, revoke or re-list them; Live sets and clears the list; a bad list is refused; a second boot writes++ * nothing.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const api = (path, o = {}) => t.get(`/api/v1${path}`, o);++ const LIVE = t.network.serviceToken('live', ['inventory.item.read', 'inventory.item.grant', 'inventory.item.consume', 'inventory.definition.manage']);++ const QUEST = t.network.serviceToken('quest', ['inventory.item.grant', 'inventory.item.consume', 'inventory.definition.manage']);++ const A = 'usr_01JZ0000000000000000000AAA';++ const inv = t.ctx.inv;++ try {++ await check('the boot seed names Quest on the six reward items, and only those', async () => {++ const live = (await inv.listDefinitions({ issuer: 'service:live', limit: 500 }));++ const named = live.filter((d) => (d.grantors || []).length).map((d) => d.aliases[0]).sort();++ assert.deepStrictEqual(named, ['fx_fire', 'fx_ice', 'fx_rainbow', 'hat_basic_cap', 'px_hearts', 'px_sparkle']);++ assert.ok(live.filter((d) => d.grantors).every((d) => JSON.stringify(d.grantors) === '["service:quest"]'));++ const r = await api(`/definitions/${named.length && (await inv.byAlias('service:live', 'px_sparkle')).id}`);++ assert.deepStrictEqual(r.json().definition.grantors, ['service:quest'], 'the public definition says who may grant it');++ const page = await t.get(`/items/${(await inv.byAlias('service:live', 'px_sparkle')).id}`);++ assert.ok(page.text.includes('How to get it') && page.text.includes('https://openvibe.quest/'), 'its page says where to earn it');++ const golden = await t.get(`/items/${(await inv.byAlias('service:live', 'fx_golden')).id}`);++ assert.ok(!golden.text.includes('How to get it'));++ const again = await require('../server/inventory/seed').seed(inv, { log: { log() {} } });++ assert.strictEqual(again.grantors, 0, 'a second boot changes nothing');++ });++++ await check('Quest grants a reward: earned, idempotent per its key, Quest the actor, Live the issuer', async () => {++ const sparkle = await inv.byAlias('service:live', 'px_sparkle');++ const g = await api('/grants', { bearer: QUEST, json: { definition_id: sparkle.id, subject: A, idempotency_key: 'quest:say-hello-in-chat:1', origin: 'earned', reason: 'Quest: Say hello in chat' } });++ assert.strictEqual(g.status, 201, g.text);++ assert.deepStrictEqual([g.json().instance.origin, g.json().instance.owner], ['earned', A]);++ const twice = await api('/grants', { bearer: QUEST, json: { definition_id: sparkle.id, subject: A, idempotency_key: 'quest:say-hello-in-chat:1', origin: 'earned' } });++ assert.deepStrictEqual([twice.status, twice.json().instance.id], [200, g.json().instance.id], 'the same key answers the same instance');++ const row = await t.ctx.s.db.maybe("SELECT actor, detail FROM inventory_ledger WHERE instance_id = $1 AND action = 'earned'", [g.json().instance.id]);++ assert.strictEqual(row.actor, 'service:quest');++ const ev = (await t.ctx.s.db.many('SELECT envelope FROM event_outbox ORDER BY id')).map((r) => r.envelope).filter((e) => e.event_type === 'inventory.item.granted').pop();++ assert.strictEqual(ev.payload.issuer, 'service:live', 'the event names the item\'s issuer');++ assert.match(JSON.stringify(ev.actor), /quest/, 'and Quest as its actor');++ });++++ await check('a grantor only grants: no other item, no edit, no revoke', async () => {++ const golden = await inv.byAlias('service:live', 'fx_golden');++ const other = await api('/grants', { bearer: QUEST, json: { definition_id: golden.id, subject: A, idempotency_key: 'quest:other:0001' } });++ assert.deepStrictEqual([other.status, other.json().code], [403, 'inventory.not_issuer']);++ const sparkle = await inv.byAlias('service:live', 'px_sparkle');++ const edit = await api(`/definitions/${sparkle.id}`, { bearer: QUEST, method: 'PATCH', json: { grantors: ['service:quest', 'service:games'] } });++ assert.deepStrictEqual([edit.status, edit.json().code], [403, 'inventory.not_issuer'], 'only the issuer changes the list');++ const owned = (await inv.inventory(A, { own: true })).instances.find((i) => i.definition_id === sparkle.id);++ const revoke = await api(`/instances/${owned.id}/revoke`, { bearer: QUEST, method: 'POST', json: { reason: 'no' } });++ assert.strictEqual(revoke.status, 403, 'revoking stays the issuer\'s');++ });++++ await check('Live sets and clears the list; a bad list is refused', async () => {++ const cap = await inv.byAlias('service:live', 'hat_basic_cap');++ const bad = await api(`/definitions/${cap.id}`, { bearer: LIVE, method: 'PATCH', json: { grantors: ['user:usr_01JZ0000000000000000000AAA'] } });++ assert.deepStrictEqual([bad.status, bad.json().code], [422, 'inventory.bad_grantors'], 'a person is never a grantor');++ const dup = await api(`/definitions/${cap.id}`, { bearer: LIVE, method: 'PATCH', json: { grantors: ['service:quest', 'service:quest'] } });++ assert.strictEqual(dup.status, 422);++ const cleared = await api(`/definitions/${cap.id}`, { bearer: LIVE, method: 'PATCH', json: { grantors: [] } });++ assert.strictEqual(cleared.status, 200, cleared.text);++ assert.strictEqual(cleared.json().definition.grantors, undefined);++ const refused = await api('/grants', { bearer: QUEST, json: { definition_id: cap.id, subject: A, idempotency_key: 'quest:go-live:0001', origin: 'earned' } });++ assert.strictEqual(refused.status, 403, 'cleared: Quest no longer grants it');++ });++ } finally {++ await t.close();++ }++ done();++})();@@@ -37,7 +37,7 @@ const { boot, check, done } = require('./helpers/boot');assert.strictEqual(crown.rarity, 'epic');// A second boot on the same database seeds nothing again.const { seed } = require('../server/inventory/seed');-- assert.deepStrictEqual(await seed(t.ctx.inv, { log: { log() {} } }), { kinds: 5, definitions: 0 });++ assert.deepStrictEqual(await seed(t.ctx.inv, { log: { log() {} } }), { kinds: 5, definitions: 0, grantors: 0 });});await check('an issuer grants within its namespace, idempotently; a non-stackable item already owned answers the one held', async () => {@@@ -57,7 +57,9 @@ const { boot, check, done } = require('./helpers/boot');});await check('only the kind\'s issuer defines or grants its items; people and missing capabilities are refused', async () => {-- const wrong = await api('/grants', { bearer: QUEST, json: { definition_id: rainbow.id, subject: bob.subject, idempotency_key: 'quest-0000-0001' } });++ // Golden Name names no grantors (Rainbow does: Quest gives it, test/grantors.test.js).++ const golden = await t.ctx.inv.byAlias('service:live', 'fx_golden');++ const wrong = await api('/grants', { bearer: QUEST, json: { definition_id: golden.id, subject: bob.subject, idempotency_key: 'quest-0000-0001' } });assert.deepStrictEqual([wrong.status, wrong.json().code], [403, 'inventory.not_issuer']);const def = await api('/definitions', { bearer: QUEST, json: { kind: 'live.hat', name: 'Quest hat', art: { emoji: '🎩' }, rarity: 'rare', attributes: { tier: 3 } } });assert.deepStrictEqual([def.status, def.json().code], [403, 'inventory.not_issuer']);
@@ -2,6 +2,16 @@
@@ -2,6 +2,16 @@
What changed in OpenVibe.Inventory, newest first. Each site also publishes its patch notes at /updates.
What changed in OpenVibe.Inventory, newest first. Each site also publishes its patch notes at /updates.
+## 0.3.0 — 2026-10-09
+
+- **Grantors** (ADR-054 §3 amendment, openvibe-contracts 0.123.0): a definition may name the services or apps that may
+ grant it. A grantor only grants (earned or granted, idempotent per its own key, within the cap), and the ledger and
+ the `inventory.item.granted` event record it as the actor with the issuer kept. The issuer sets and clears the list
+ with `PATCH /definitions/:id`; a person is never a grantor (`migrations/0002_grantors.sql`).
+- Live lets OpenVibe.Quest give six common items as quest rewards: Sparkle, Hearts, Basic Cap, Fire Name, Ice Name
+ and Rainbow Name (`server/data/live-grantors.json`, set at boot). Their item pages say how to get them.
+- The public reads answer every origin (#4), and the sitemap and llms.txt list every item, kind and the public API (#3).
+
## 0.2.0 — 2026-10-09
## 0.2.0 — 2026-10-09
- **The inventory (ADR-054, plan T21 step 2).**
- **The inventory (ADR-054, plan T21 step 2).**
@@ -23,7 +23,11 @@ people do; people keep them in one place and wear them on every site that shows
@@ -23,7 +23,11 @@ people do; people keep them in one place and wear them on every site that shows
- **Equipped:** one instance per kind and slot.
- **Equipped:** one instance per kind and slot.
- **Ledger:** an append-only row for every movement, with who did it and why.
- **Ledger:** an append-only row for every movement, with who did it and why.
-Only a kind's issuer defines and grants its items: `service:live` for `live.*`. **Nothing is sold, bought, traded or
+Only a kind's issuer defines and grants its items: `service:live` for `live.*`. An issuer may name **grantors** on
+one of its items (ADR-054 §3 amendment): other services or apps that may grant that item and nothing more (no edit,
+no revoke), recorded in the ledger as the actor. Live names OpenVibe.Quest on six common items, so they are earned as
+quest rewards ([server/data/live-grantors.json](server/data/live-grantors.json), applied at boot); their pages say
+where to earn them. **Nothing is sold, bought, traded or
converted into OpenCoins or Vibes**, and there are no paid random rewards, until a later ADR covers the legal basis and
converted into OpenCoins or Vibes**, and there are no paid random rewards, until a later ADR covers the legal basis and
Billing (ADR-054 §5). A test fails if a route or a column for that appears.
Billing (ADR-054 §5). A test fails if a route or a column for that appears.
@@ -119,7 +123,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
@@ -119,7 +123,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.122.1
+- openvibe-contracts: v0.123.0
- openvibe-sdk: v0.37.0
- openvibe-sdk: v0.37.0
- openvibe-shared: v2.17.0
- openvibe-shared: v2.17.0
<!-- versions:end -->
<!-- versions:end -->
@@ -6,11 +6,11 @@
@@ -6,11 +6,11 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": "5030",
"port": "5030",
"domain": "inventory.openvibe.network",
"domain": "inventory.openvibe.network",
- "contracts": "openvibe-contracts v0.122.1",
+ "contracts": "openvibe-contracts v0.123.0",
"sdk": "openvibe-sdk v0.37.0",
"sdk": "openvibe-sdk v0.37.0",
"shared": "openvibe-shared v2.17.0",
"shared": "openvibe-shared v2.17.0",
"packages": [
"packages": [
- "openvibe-contracts v0.122.1",
+ "openvibe-contracts v0.123.0",
"openvibe-sdk v0.37.0",
"openvibe-sdk v0.37.0",
"openvibe-shared v2.17.0"
"openvibe-shared v2.17.0"
],
],
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+-- phase: expand
+-- ADR-054 §3 amendment (Contracts 0.123.0): a definition may name grantors, other services or apps its issuer lets
+-- grant that one item (OpenVibe.Quest giving some of Live's hats as quest rewards). Grant only: defining, editing,
+-- consuming and revoking stay the issuer's. Additive; an older release never reads the column.
+ALTER TABLE inventory_definitions ADD COLUMN IF NOT EXISTS grantors jsonb NOT NULL DEFAULT '[]'::jsonb;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-inventory",
"name": "openvibe-inventory",
- "version": "0.2.0",
+ "version": "0.3.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-inventory",
"name": "openvibe-inventory",
- "version": "0.2.0",
+ "version": "0.3.0",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.20.0",
"ajv": "^8.20.0",
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.122.1",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.123.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.17.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.17.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -872,9 +872,9 @@
@@ -872,9 +872,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.122.1",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.122.1",
- "integrity": "sha512-+edJcURx0E76QWdgjr42fl0OgQxdWl7FA1OuWpopDKfO4ndidvf+GEz5skt1upSN4JLecLWrpMMyXJN27GjZsQ==",
+ "version": "0.123.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.123.0",
+ "integrity": "sha512-aO9o9qADB5wQ6XISPprGZ43P0eEIn/4aQmNVECe6YkDs4NFYvZD8TVPAzrmRk2tWFyIzaYYo3MTxy6ku71y6+w==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-inventory",
"name": "openvibe-inventory",
- "version": "0.2.0",
+ "version": "0.3.0",
"private": true,
"private": true,
"description": "OpenVibe.Inventory — Your items, on every OpenVibe site.",
"description": "OpenVibe.Inventory — Your items, on every OpenVibe site.",
"main": "server/index.js",
"main": "server/index.js",
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.122.1",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.123.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.37.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.17.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.17.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+{
+ "_about": "Live's items that other services may grant (ADR-054 §3 grantors), by Live's item id. OpenVibe.Quest gives these as quest rewards: Sparkle for saying hello in chat, Hearts for a first follow, the Basic Cap for going live, Fire Name for streaming three times, Ice Name for starting a thread, Rainbow Name for improving a wiki page.",
+ "grantors": {
+ "px_sparkle": ["service:quest"],
+ "px_hearts": ["service:quest"],
+ "hat_basic_cap": ["service:quest"],
+ "fx_fire": ["service:quest"],
+ "fx_ice": ["service:quest"],
+ "fx_rainbow": ["service:quest"]
+ }
+}
@@ -149,6 +149,8 @@ ${shown.map((k) => html`<section class="inv-kind" aria-labelledby="k-${k.id.repl
@@ -149,6 +149,8 @@ ${shown.map((k) => html`<section class="inv-kind" aria-labelledby="k-${k.id.repl
<li><b>${d.supply.cap == null ? 'Unlimited' : `${d.supply.cap - d.supply.issued} of ${d.supply.cap}`}</b> ${d.supply.cap == null ? 'supply' : 'left'}</li>
<li><b>${d.supply.cap == null ? 'Unlimited' : `${d.supply.cap - d.supply.issued} of ${d.supply.cap}`}</b> ${d.supply.cap == null ? 'supply' : 'left'}</li>
<li><b>${k.slots.length ? 'Wearable' : 'Collectible'}</b> ${k.slots.length ? `in the ${k.slots.join(', ')} slot` : 'shown on your profile'}</li>
<li><b>${k.slots.length ? 'Wearable' : 'Collectible'}</b> ${k.slots.length ? `in the ${k.slots.join(', ')} slot` : 'shown on your profile'}</li>
</ul>
</ul>
+${(d.grantors || []).includes('service:quest') && d.status === 'published' ? html`<h2>How to get it</h2>
+<p>Earn it on <a href="https://openvibe.quest/">OpenVibe.Quest</a>: it is the reward for one of the quests there, given once to everyone who completes it.</p>` : ''}
<h2>Where it shows</h2>
<h2>Where it shows</h2>
<p>${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ').replace(/^./, (c) => c.toUpperCase())}.</p>
<p>${k.surfaces.map((s) => surfaceLabel(s.surface)).join(', ').replace(/^./, (c) => c.toUpperCase())}.</p>
<h2>Who gives it</h2>
<h2>Who gives it</h2>
@@ -5,6 +5,8 @@
@@ -5,6 +5,8 @@
* server/data/kinds.json the kinds (contracts with renderers); an existing kind is updated in place
* server/data/kinds.json the kinds (contracts with renderers); an existing kind is updated in place
* server/data/live-definitions.json Live's cosmetics catalog as definitions issued by service:live, found again by their
* server/data/live-definitions.json Live's cosmetics catalog as definitions issued by service:live, found again by their
* alias (Live's item id); a definition already there is left as it is
* alias (Live's item id); a definition already there is left as it is
+ * server/data/live-grantors.json the services Live lets grant some of its items (ADR-054 §3), set on the definition
+ * whenever the file and the database differ (Quest's quest rewards)
* A boot that changes nothing writes nothing.
* A boot that changes nothing writes nothing.
*/
*/
const path = require('path');
const path = require('path');
@@ -23,7 +25,16 @@ async function seed(inv, { log = console } = {}) {
@@ -23,7 +25,16 @@ async function seed(inv, { log = console } = {}) {
made++;
made++;
}
}
if (made) log.log(`[OpenVibe.Inventory] seeded ${made} of Live's cosmetics as definitions`);
if (made) log.log(`[OpenVibe.Inventory] seeded ${made} of Live's cosmetics as definitions`);
- return { kinds: kinds.length, definitions: made };
+ const { grantors } = require(path.join(DATA, 'live-grantors.json'));
+ let granting = 0;
+ for (const [alias, list] of Object.entries(grantors)) {
+ const d = await inv.byAlias(LIVE, alias);
+ if (!d || JSON.stringify(d.grantors || []) === JSON.stringify(list)) continue;
+ await inv.updateDefinition(LIVE, d.id, { grantors: list });
+ granting++;
+ }
+ if (granting) log.log(`[OpenVibe.Inventory] set the grantors of ${granting} of Live's items`);
+ return { kinds: kinds.length, definitions: made, grantors: granting };
}
}
module.exports = { seed, LIVE };
module.exports = { seed, LIVE };
@@ -18,6 +18,8 @@ const Ajv = require('ajv/dist/2020');
@@ -18,6 +18,8 @@ const Ajv = require('ajv/dist/2020');
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const DEF_RE = /^itd_[0-9A-HJKMNP-TV-Z]{26}$/;
const DEF_RE = /^itd_[0-9A-HJKMNP-TV-Z]{26}$/;
+// Who an issuer may name as a grantor of one of its items (ADR-054 §3): a service or an app, never a person.
+const GRANTOR_RE = /^(service:[a-z][a-z0-9-]*|app:app_[0-9A-HJKMNP-TV-Z]{26})$/;
const INST_RE = /^inv_[0-9A-HJKMNP-TV-Z]{26}$/;
const INST_RE = /^inv_[0-9A-HJKMNP-TV-Z]{26}$/;
const RARITY = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
const RARITY = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
@@ -44,6 +46,7 @@ function definitionRow(r, aliases = []) {
@@ -44,6 +46,7 @@ function definitionRow(r, aliases = []) {
};
};
if (r.description) out.description = r.description;
if (r.description) out.description = r.description;
if (aliases.length) out.aliases = aliases;
if (aliases.length) out.aliases = aliases;
+ if (Array.isArray(r.grantors) && r.grantors.length) out.grantors = r.grantors;
if (r.published_at) out.published_at = r.published_at;
if (r.published_at) out.published_at = r.published_at;
if (r.credit_subject || r.credit_name) out.credit = { ...(r.credit_subject ? { subject: r.credit_subject } : {}), ...(r.credit_name ? { name: r.credit_name } : {}) };
if (r.credit_subject || r.credit_name) out.credit = { ...(r.credit_subject ? { subject: r.credit_subject } : {}), ...(r.credit_name ? { name: r.credit_name } : {}) };
return out;
return out;
@@ -141,6 +144,7 @@ function createInventory({ s, outbox, log = console }) {
@@ -141,6 +144,7 @@ function createInventory({ s, outbox, log = console }) {
}
}
if (!partial || has('rarity')) if (!RARITY.includes(b.rarity)) fail(422, 'inventory.bad_rarity', `rarity is one of ${RARITY.join(', ')}`);
if (!partial || has('rarity')) if (!RARITY.includes(b.rarity)) fail(422, 'inventory.bad_rarity', `rarity is one of ${RARITY.join(', ')}`);
if (has('supply_cap') && b.supply_cap !== null && !(Number.isInteger(b.supply_cap) && b.supply_cap > 0)) fail(422, 'inventory.bad_supply', 'supply_cap is a positive whole number or null');
if (has('supply_cap') && b.supply_cap !== null && !(Number.isInteger(b.supply_cap) && b.supply_cap > 0)) fail(422, 'inventory.bad_supply', 'supply_cap is a positive whole number or null');
+ if (has('grantors') && (!Array.isArray(b.grantors) || b.grantors.length > 8 || new Set(b.grantors).size !== b.grantors.length || b.grantors.some((x) => !GRANTOR_RE.test(String(x))))) fail(422, 'inventory.bad_grantors', 'grantors: up to 8 service:… or app:app_… subjects');
if (has('aliases') && (!Array.isArray(b.aliases) || b.aliases.length > 8 || b.aliases.some((x) => !/^[a-z0-9][a-z0-9_.-]{0,63}$/.test(String(x))))) fail(422, 'inventory.bad_alias', 'aliases: up to 8 lower-case ids');
if (has('aliases') && (!Array.isArray(b.aliases) || b.aliases.length > 8 || b.aliases.some((x) => !/^[a-z0-9][a-z0-9_.-]{0,63}$/.test(String(x))))) fail(422, 'inventory.bad_alias', 'aliases: up to 8 lower-case ids');
}
}
@@ -156,9 +160,9 @@ function createInventory({ s, outbox, log = console }) {
@@ -156,9 +160,9 @@ function createInventory({ s, outbox, log = console }) {
const now = s.iso();
const now = s.iso();
const st = status || (b.publish ? 'published' : 'draft');
const st = status || (b.publish ? 'published' : 'draft');
return await s.tx(async () => {
return await s.tx(async () => {
- await db.exec(`INSERT INTO inventory_definitions (id, kind, name, description, art, rarity, attributes, issuer, supply_cap, status, created_at, updated_at, published_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11, $12)`,
- [id, kind.id, b.name.trim(), b.description || null, JSON.stringify(b.art), b.rarity, JSON.stringify(b.attributes || {}), requester, b.supply_cap || null, st, now, st === 'published' ? now : null]);
+ await db.exec(`INSERT INTO inventory_definitions (id, kind, name, description, art, rarity, attributes, issuer, supply_cap, status, created_at, updated_at, published_at, grantors)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11, $12, $13)`,
+ [id, kind.id, b.name.trim(), b.description || null, JSON.stringify(b.art), b.rarity, JSON.stringify(b.attributes || {}), requester, b.supply_cap || null, st, now, st === 'published' ? now : null, JSON.stringify(b.grantors || [])]);
for (const alias of b.aliases || []) {
for (const alias of b.aliases || []) {
const taken = await db.maybe('SELECT definition_id FROM inventory_definition_aliases WHERE issuer = $1 AND alias = $2', [requester, alias]);
const taken = await db.maybe('SELECT definition_id FROM inventory_definition_aliases WHERE issuer = $1 AND alias = $2', [requester, alias]);
if (taken) fail(409, 'inventory.alias_taken', `${alias} already names ${taken.definition_id}`);
if (taken) fail(409, 'inventory.alias_taken', `${alias} already names ${taken.definition_id}`);
@@ -186,12 +190,15 @@ function createInventory({ s, outbox, log = console }) {
@@ -186,12 +190,15 @@ function createInventory({ s, outbox, log = console }) {
else if (b.publish && cur.status === 'draft') status = 'published';
else if (b.publish && cur.status === 'draft') status = 'published';
else if (b.status !== undefined && b.status !== cur.status && b.status !== 'retired') fail(422, 'inventory.bad_status', 'status: retired, or publish: true on a draft');
else if (b.status !== undefined && b.status !== cur.status && b.status !== 'retired') fail(422, 'inventory.bad_status', 'status: retired, or publish: true on a draft');
const now = s.iso();
const now = s.iso();
+ // grantors change any time, published or not: they say who may grant the item, not what it is (ADR-054 §3).
+ const grantors = b.grantors !== undefined ? b.grantors : (cur.grantors || []);
await db.exec(`UPDATE inventory_definitions SET name = $2, description = $3, art = $4, rarity = $5, attributes = $6, supply_cap = $7, status = $8,
await db.exec(`UPDATE inventory_definitions SET name = $2, description = $3, art = $4, rarity = $5, attributes = $6, supply_cap = $7, status = $8,
- updated_at = $9, published_at = COALESCE(published_at, CASE WHEN $8 = 'published' THEN $9 END) WHERE id = $1`,
+ updated_at = $9, published_at = COALESCE(published_at, CASE WHEN $8 = 'published' THEN $9 END), grantors = $10 WHERE id = $1`,
[cur.id, b.name !== undefined ? b.name.trim() : cur.name, b.description !== undefined ? b.description : cur.description,
[cur.id, b.name !== undefined ? b.name.trim() : cur.name, b.description !== undefined ? b.description : cur.description,
JSON.stringify(b.art !== undefined ? b.art : cur.art), b.rarity !== undefined ? b.rarity : cur.rarity,
JSON.stringify(b.art !== undefined ? b.art : cur.art), b.rarity !== undefined ? b.rarity : cur.rarity,
- JSON.stringify(b.attributes !== undefined ? b.attributes : cur.attributes), b.supply_cap !== undefined ? b.supply_cap : cur.supply_cap, status, now]);
- await ledger(status !== cur.status ? status : 'edited', { definition: cur.id, actor: requester });
+ JSON.stringify(b.attributes !== undefined ? b.attributes : cur.attributes), b.supply_cap !== undefined ? b.supply_cap : cur.supply_cap, status, now, JSON.stringify(grantors)]);
+ const grantorsChanged = b.grantors !== undefined && JSON.stringify(grantors) !== JSON.stringify(cur.grantors || []);
+ await ledger(status !== cur.status ? status : 'edited', { definition: cur.id, actor: requester, ...(grantorsChanged ? { detail: { grantors } } : {}) });
if (status === 'published' && cur.status !== 'published') {
if (status === 'published' && cur.status !== 'published') {
await emit('inventory.definition.published', { subject: { type: 'item_definition', id: cur.id }, visibility: 'public', actor: requester, payload: { definition_id: cur.id, kind: cur.kind, name: b.name !== undefined ? b.name.trim() : cur.name, rarity: b.rarity || cur.rarity, issuer: cur.issuer } });
await emit('inventory.definition.published', { subject: { type: 'item_definition', id: cur.id }, visibility: 'public', actor: requester, payload: { definition_id: cur.id, kind: cur.kind, name: b.name !== undefined ? b.name.trim() : cur.name, rarity: b.rarity || cur.rarity, issuer: cur.issuer } });
}
}
@@ -218,7 +225,8 @@ function createInventory({ s, outbox, log = console }) {
@@ -218,7 +225,8 @@ function createInventory({ s, outbox, log = console }) {
if (prior) return { instance: instanceRow(await db.maybe('SELECT * FROM inventory_instances WHERE id = $1', [prior.instance_id])), created: false };
if (prior) return { instance: instanceRow(await db.maybe('SELECT * FROM inventory_instances WHERE id = $1', [prior.instance_id])), created: false };
const def = await db.maybe('SELECT * FROM inventory_definitions WHERE id = $1 FOR UPDATE', [b.definition_id]);
const def = await db.maybe('SELECT * FROM inventory_definitions WHERE id = $1 FOR UPDATE', [b.definition_id]);
if (!def) fail(404, 'inventory.definition_not_found', 'no such definition');
if (!def) fail(404, 'inventory.definition_not_found', 'no such definition');
- if (def.issuer !== requester) fail(403, 'inventory.not_issuer', `only ${def.issuer} grants this item`);
+ const isGrantor = Array.isArray(def.grantors) && def.grantors.includes(requester);
+ if (def.issuer !== requester && !isGrantor) fail(403, 'inventory.not_issuer', `only ${def.issuer}${(def.grantors || []).length ? ' and the grantors it names' : ''} grant this item`);
if (def.status !== 'published') fail(409, 'inventory.not_published', `the item is ${def.status}`);
if (def.status !== 'published') fail(409, 'inventory.not_published', `the item is ${def.status}`);
const kind = await getKind(def.kind);
const kind = await getKind(def.kind);
if (b.attributes !== undefined) await checkAttributes(kind.id, { ...def.attributes, ...b.attributes });
if (b.attributes !== undefined) await checkAttributes(kind.id, { ...def.attributes, ...b.attributes });
@@ -239,7 +247,7 @@ function createInventory({ s, outbox, log = console }) {
@@ -239,7 +247,7 @@ function createInventory({ s, outbox, log = console }) {
await db.exec('INSERT INTO inventory_grants (issuer, idempotency_key, instance_id, created_at) VALUES ($1, $2, $3, $4)', [requester, key, id, now]);
await db.exec('INSERT INTO inventory_grants (issuer, idempotency_key, instance_id, created_at) VALUES ($1, $2, $3, $4)', [requester, key, id, now]);
await ledger(origin, { owner: b.subject, instance: id, definition: def.id, actor: requester, reason: b.reason || null });
await ledger(origin, { owner: b.subject, instance: id, definition: def.id, actor: requester, reason: b.reason || null });
await emit('inventory.item.granted', { subject: { type: 'user', id: b.subject }, visibility: 'subject', actor: requester,
await emit('inventory.item.granted', { subject: { type: 'user', id: b.subject }, visibility: 'subject', actor: requester,
- payload: { instance_id: id, definition_id: def.id, kind: def.kind, owner: b.subject, origin, issuer: requester } });
+ payload: { instance_id: id, definition_id: def.id, kind: def.kind, owner: b.subject, origin, issuer: def.issuer } }); // a grantor is the event's actor
return { instance: instanceRow(await db.maybe('SELECT * FROM inventory_instances WHERE id = $1', [id])), created: true };
return { instance: instanceRow(await db.maybe('SELECT * FROM inventory_instances WHERE id = $1', [id])), created: true };
});
});
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+'use strict';
+/**
+ * Grantors (ADR-054 §3 amendment, Contracts 0.123.0): Live, the issuer of its hats and effects, lets OpenVibe.Quest
+ * grant six of them as quest rewards (server/data/live-grantors.json, set at boot). Quest grants those (earned,
+ * idempotent per its own key, recorded with Quest as the actor, the event naming Live as the issuer) and nothing else;
+ * it cannot edit, revoke or re-list them; Live sets and clears the list; a bad list is refused; a second boot writes
+ * nothing.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const api = (path, o = {}) => t.get(`/api/v1${path}`, o);
+ const LIVE = t.network.serviceToken('live', ['inventory.item.read', 'inventory.item.grant', 'inventory.item.consume', 'inventory.definition.manage']);
+ const QUEST = t.network.serviceToken('quest', ['inventory.item.grant', 'inventory.item.consume', 'inventory.definition.manage']);
+ const A = 'usr_01JZ0000000000000000000AAA';
+ const inv = t.ctx.inv;
+ try {
+ await check('the boot seed names Quest on the six reward items, and only those', async () => {
+ const live = (await inv.listDefinitions({ issuer: 'service:live', limit: 500 }));
+ const named = live.filter((d) => (d.grantors || []).length).map((d) => d.aliases[0]).sort();
+ assert.deepStrictEqual(named, ['fx_fire', 'fx_ice', 'fx_rainbow', 'hat_basic_cap', 'px_hearts', 'px_sparkle']);
+ assert.ok(live.filter((d) => d.grantors).every((d) => JSON.stringify(d.grantors) === '["service:quest"]'));
+ const r = await api(`/definitions/${named.length && (await inv.byAlias('service:live', 'px_sparkle')).id}`);
+ assert.deepStrictEqual(r.json().definition.grantors, ['service:quest'], 'the public definition says who may grant it');
+ const page = await t.get(`/items/${(await inv.byAlias('service:live', 'px_sparkle')).id}`);
+ assert.ok(page.text.includes('How to get it') && page.text.includes('https://openvibe.quest/'), 'its page says where to earn it');
+ const golden = await t.get(`/items/${(await inv.byAlias('service:live', 'fx_golden')).id}`);
+ assert.ok(!golden.text.includes('How to get it'));
+ const again = await require('../server/inventory/seed').seed(inv, { log: { log() {} } });
+ assert.strictEqual(again.grantors, 0, 'a second boot changes nothing');
+ });
+
+ await check('Quest grants a reward: earned, idempotent per its key, Quest the actor, Live the issuer', async () => {
+ const sparkle = await inv.byAlias('service:live', 'px_sparkle');
+ const g = await api('/grants', { bearer: QUEST, json: { definition_id: sparkle.id, subject: A, idempotency_key: 'quest:say-hello-in-chat:1', origin: 'earned', reason: 'Quest: Say hello in chat' } });
+ assert.strictEqual(g.status, 201, g.text);
+ assert.deepStrictEqual([g.json().instance.origin, g.json().instance.owner], ['earned', A]);
+ const twice = await api('/grants', { bearer: QUEST, json: { definition_id: sparkle.id, subject: A, idempotency_key: 'quest:say-hello-in-chat:1', origin: 'earned' } });
+ assert.deepStrictEqual([twice.status, twice.json().instance.id], [200, g.json().instance.id], 'the same key answers the same instance');
+ const row = await t.ctx.s.db.maybe("SELECT actor, detail FROM inventory_ledger WHERE instance_id = $1 AND action = 'earned'", [g.json().instance.id]);
+ assert.strictEqual(row.actor, 'service:quest');
+ const ev = (await t.ctx.s.db.many('SELECT envelope FROM event_outbox ORDER BY id')).map((r) => r.envelope).filter((e) => e.event_type === 'inventory.item.granted').pop();
+ assert.strictEqual(ev.payload.issuer, 'service:live', 'the event names the item\'s issuer');
+ assert.match(JSON.stringify(ev.actor), /quest/, 'and Quest as its actor');
+ });
+
+ await check('a grantor only grants: no other item, no edit, no revoke', async () => {
+ const golden = await inv.byAlias('service:live', 'fx_golden');
+ const other = await api('/grants', { bearer: QUEST, json: { definition_id: golden.id, subject: A, idempotency_key: 'quest:other:0001' } });
+ assert.deepStrictEqual([other.status, other.json().code], [403, 'inventory.not_issuer']);
+ const sparkle = await inv.byAlias('service:live', 'px_sparkle');
+ const edit = await api(`/definitions/${sparkle.id}`, { bearer: QUEST, method: 'PATCH', json: { grantors: ['service:quest', 'service:games'] } });
+ assert.deepStrictEqual([edit.status, edit.json().code], [403, 'inventory.not_issuer'], 'only the issuer changes the list');
+ const owned = (await inv.inventory(A, { own: true })).instances.find((i) => i.definition_id === sparkle.id);
+ const revoke = await api(`/instances/${owned.id}/revoke`, { bearer: QUEST, method: 'POST', json: { reason: 'no' } });
+ assert.strictEqual(revoke.status, 403, 'revoking stays the issuer\'s');
+ });
+
+ await check('Live sets and clears the list; a bad list is refused', async () => {
+ const cap = await inv.byAlias('service:live', 'hat_basic_cap');
+ const bad = await api(`/definitions/${cap.id}`, { bearer: LIVE, method: 'PATCH', json: { grantors: ['user:usr_01JZ0000000000000000000AAA'] } });
+ assert.deepStrictEqual([bad.status, bad.json().code], [422, 'inventory.bad_grantors'], 'a person is never a grantor');
+ const dup = await api(`/definitions/${cap.id}`, { bearer: LIVE, method: 'PATCH', json: { grantors: ['service:quest', 'service:quest'] } });
+ assert.strictEqual(dup.status, 422);
+ const cleared = await api(`/definitions/${cap.id}`, { bearer: LIVE, method: 'PATCH', json: { grantors: [] } });
+ assert.strictEqual(cleared.status, 200, cleared.text);
+ assert.strictEqual(cleared.json().definition.grantors, undefined);
+ const refused = await api('/grants', { bearer: QUEST, json: { definition_id: cap.id, subject: A, idempotency_key: 'quest:go-live:0001', origin: 'earned' } });
+ assert.strictEqual(refused.status, 403, 'cleared: Quest no longer grants it');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
@@ -37,7 +37,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -37,7 +37,7 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(crown.rarity, 'epic');
assert.strictEqual(crown.rarity, 'epic');
// A second boot on the same database seeds nothing again.
// A second boot on the same database seeds nothing again.
const { seed } = require('../server/inventory/seed');
const { seed } = require('../server/inventory/seed');
- assert.deepStrictEqual(await seed(t.ctx.inv, { log: { log() {} } }), { kinds: 5, definitions: 0 });
+ assert.deepStrictEqual(await seed(t.ctx.inv, { log: { log() {} } }), { kinds: 5, definitions: 0, grantors: 0 });
});
});
await check('an issuer grants within its namespace, idempotently; a non-stackable item already owned answers the one held', async () => {
await check('an issuer grants within its namespace, idempotently; a non-stackable item already owned answers the one held', async () => {
@@ -57,7 +57,9 @@ const { boot, check, done } = require('./helpers/boot');
@@ -57,7 +57,9 @@ const { boot, check, done } = require('./helpers/boot');
});
});
await check('only the kind\'s issuer defines or grants its items; people and missing capabilities are refused', async () => {
await check('only the kind\'s issuer defines or grants its items; people and missing capabilities are refused', async () => {
- const wrong = await api('/grants', { bearer: QUEST, json: { definition_id: rainbow.id, subject: bob.subject, idempotency_key: 'quest-0000-0001' } });
+ // Golden Name names no grantors (Rainbow does: Quest gives it, test/grantors.test.js).
+ const golden = await t.ctx.inv.byAlias('service:live', 'fx_golden');
+ const wrong = await api('/grants', { bearer: QUEST, json: { definition_id: golden.id, subject: bob.subject, idempotency_key: 'quest-0000-0001' } });
assert.deepStrictEqual([wrong.status, wrong.json().code], [403, 'inventory.not_issuer']);
assert.deepStrictEqual([wrong.status, wrong.json().code], [403, 'inventory.not_issuer']);
const def = await api('/definitions', { bearer: QUEST, json: { kind: 'live.hat', name: 'Quest hat', art: { emoji: '🎩' }, rarity: 'rare', attributes: { tier: 3 } } });
const def = await api('/definitions', { bearer: QUEST, json: { kind: 'live.hat', name: 'Quest hat', art: { emoji: '🎩' }, rarity: 'rare', attributes: { tier: 3 } } });
assert.deepStrictEqual([def.status, def.json().code], [403, 'inventory.not_issuer']);
assert.deepStrictEqual([def.status, def.json().code], [403, 'inventory.not_issuer']);