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)

OP OpenVibers committed 9 Oct 2026 · 4e2e674404 · 12 files, +153 −23

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 30c2f71..546d60d 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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).**
ddiff --git a/README.md b/README.md
iindex eb53e00..ce08dfd 100644
---- a/README.md
++++ b/README.md
@@@ -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 or
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.
@@@ -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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex d7009ed..5b42159 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
],
ddiff --git a/migrations/0002_grantors.sql b/migrations/0002_grantors.sql
nnew file mode 100644
iindex 0000000..bb43f04
---- /dev/null
++++ b/migrations/0002_grantors.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex 04b014a..fa9771d 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 0fc2901..422921b 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/data/live-grantors.json b/server/data/live-grantors.json
nnew file mode 100644
iindex 0000000..b491f8c
---- /dev/null
++++ b/server/data/live-grantors.json
@@@ -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"]
++ }
++}
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex a6c7092..defe691 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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>
ddiff --git a/server/inventory/seed.js b/server/inventory/seed.js
iindex 68c6f0d..f016748 100644
---- a/server/inventory/seed.js
++++ b/server/inventory/seed.js
@@@ -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 };
ddiff --git a/server/inventory/store.js b/server/inventory/store.js
iindex 299c5d6..f3f9fa7 100644
---- a/server/inventory/store.js
++++ b/server/inventory/store.js
@@@ -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 actor
return { instance: instanceRow(await db.maybe('SELECT * FROM inventory_instances WHERE id = $1', [id])), created: true };
});
}
ddiff --git a/test/grantors.test.js b/test/grantors.test.js
nnew file mode 100644
iindex 0000000..929476c
---- /dev/null
++++ b/test/grantors.test.js
@@@ -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();
++})();
ddiff --git a/test/inventory-api.test.js b/test/inventory-api.test.js
iindex e12d37a..a38c590 100644
---- a/test/inventory-api.test.js
++++ b/test/inventory-api.test.js
@@@ -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']);