openvibers/openvibe.inventory Commits

Inventory 0.4.1: Workshop takedown — staff retire a community badge, or retire it and revoke every copy, with a reason in the ledger (#9)

OP OpenVibers committed 9 Oct 2026 · 38cfe3f71d · 6 files, +86 −6

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 5d57278..50302fe 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,12 @@
What changed in OpenVibe.Inventory, newest first. Each site also publishes its patch notes at /updates.
++## 0.4.1 — 2026-10-09
++
++- Workshop takedown (ADR-054 §6): `/workshop/review` lists the published community badges. Staff can retire one (nobody
++ gives it any more) or retire it and revoke every copy (taken off whoever wears it, each owner told through
++ `inventory.item.revoked`). Both need a reason, which goes to the ledger.
++
## 0.4.0 — 2026-10-09
- **The Workshop** (ADR-054 §6 amendment, openvibe-contracts 0.124.0): community badges, free.
ddiff --git a/package-lock.json b/package-lock.json
iindex baf29c3..6ba3435 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-inventory",
-- "version": "0.4.0",
++ "version": "0.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-inventory",
-- "version": "0.4.0",
++ "version": "0.4.1",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.20.0",
ddiff --git a/package.json b/package.json
iindex 6dee53d..8102ad9 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-inventory",
-- "version": "0.4.0",
++ "version": "0.4.1",
"private": true,
"description": "OpenVibe.Inventory — Your items, on every OpenVibe site.",
"main": "server/index.js",
ddiff --git a/server/inventory/store.js b/server/inventory/store.js
iindex dc7a881..cffe74f 100644
---- a/server/inventory/store.js
++++ b/server/inventory/store.js
@@@ -388,6 +388,35 @@ function createInventory({ s, outbox, log = console }) {
});
}
++ /**
++ * Staff take a published Workshop item down (ADR-054 §6): retire it (nobody gives it any more; owned copies stay),
++ * or, when it breaks the rules, also revoke every copy (taken off whoever wears it). Recorded in the ledger with the
++ * reason; each revoked copy also emits inventory.item.revoked to its owner.
++ */
++ async function takedown(staff, id, { revoke = false, reason } = {}) {
++ if (!(typeof reason === 'string' && reason.trim() && reason.length <= 300)) fail(422, 'inventory.bad_review', 'a takedown says why, in at most 300 characters');
++ return await s.tx(async () => {
++ const cur = await db.maybe('SELECT d.*, k.workshop FROM inventory_definitions d JOIN inventory_kinds k ON k.id = d.kind WHERE d.id = $1 FOR UPDATE OF d', [String(id || '')]);
++ if (!cur || !cur.workshop) fail(404, 'inventory.definition_not_found', 'no such Workshop item');
++ if (cur.status !== 'published' && cur.status !== 'retired') fail(409, 'inventory.not_published', `the item is ${cur.status}`);
++ const now = s.iso();
++ await db.exec("UPDATE inventory_definitions SET status = 'retired', updated_at = $2 WHERE id = $1", [cur.id, now]);
++ await ledger('retired', { definition: cur.id, actor: staff, reason: reason.trim() });
++ let revoked = 0;
++ if (revoke) {
++ const owned = await db.many("SELECT id, owner_subject FROM inventory_instances WHERE definition_id = $1 AND state = 'owned'", [cur.id]);
++ for (const inst of owned) {
++ await db.exec("UPDATE inventory_instances SET state = 'revoked', updated_at = $2 WHERE id = $1", [inst.id, now]);
++ await db.exec('DELETE FROM inventory_equipped WHERE instance_id = $1', [inst.id]);
++ await ledger('revoked', { owner: inst.owner_subject, instance: inst.id, definition: cur.id, actor: staff, reason: reason.trim() });
++ await emit('inventory.item.revoked', { subject: { type: 'user', id: inst.owner_subject }, visibility: 'subject', actor: staff, payload: { instance_id: inst.id, definition_id: cur.id, owner: inst.owner_subject, reason: reason.trim().slice(0, 200) } });
++ revoked++;
++ }
++ }
++ return { definition: await getDefinition(cur.id), revoked };
++ });
++ }
++
/** Workshop items: the review queue (staff), a creator's own (any status, with the review note), or the published ones. */
async function workshopItems({ status = null, issuer = null, limit = 100 } = {}) {
const rows = await db.many(`SELECT d.* FROM inventory_definitions d JOIN inventory_kinds k ON k.id = d.kind
@@@ -399,7 +428,7 @@ function createInventory({ s, outbox, log = console }) {
return {
listKinds, getKind, upsertKind, listDefinitions, getDefinition, byAlias, createDefinition, updateDefinition,
-- grant, consume, inventory, equipped, equip, ledger, review, workshopItems,
++ grant, consume, inventory, equipped, equip, ledger, review, workshopItems, takedown,
};
}
ddiff --git a/server/workshop/pages.js b/server/workshop/pages.js
iindex 4e20f31..890c415 100644
---- a/server/workshop/pages.js
++++ b/server/workshop/pages.js
@@@ -6,7 +6,7 @@
* /workshop what it is, the published community badges, how to make one
* /workshop/new make a badge: an image, a name, a cap, the rights you confirm (POST: multipart form)
* /workshop/mine your submissions with their review state; give a published one to someone by @name
-- * /workshop/review staff: the review queue, publish or reject with a reason
++ * /workshop/review staff: the review queue (publish or reject with a reason) and takedown of published badges
*
* A badge is shown to nobody until staff publish it: the review queue and its creator's own page are the only places
* its image appears before that. Nothing is sold, bought or traded (ADR-054 §5).
@@@ -175,6 +175,7 @@ ${mine.length ? html`<ul class="ws-mine">${mine.map((d) => html`<li class="ws-it
if (!signedIn(req)) return login(res, '/workshop/review');
if (!isStaff(req)) return page(req, res, { title: 'Staff only', robots: 'noindex', body: html`<h1>Staff only</h1><p>The review queue is for OpenVibe staff.</p>` }, 403);
const queue = await inv.workshopItems({ status: 'in_review', limit: 100 });
++ const live = await inv.workshopItems({ status: 'published', limit: 200 });
page(req, res, {
title: 'Workshop review',
robots: 'noindex',
@@@ -194,10 +195,38 @@ ${queue.length ? html`<ul class="ws-mine">${queue.map((d) => html`<li class="ws-
<button type="submit" name="decision" value="reject" class="inv-btn">Reject</button>
</form>
</div>
--</li>`)}</ul>` : html`<p class="muted">Nothing waiting.</p>`}`,
++</li>`)}</ul>` : html`<p class="muted">Nothing waiting.</p>`}
++<h2>Published <span class="muted small">${live.length}</span></h2>
++<p class="muted">Take a badge down when it breaks the rules or a rights holder asks: retiring stops anyone giving it; revoking also takes every copy off whoever has it. Both say why, in the ledger.</p>
++${live.length ? html`<ul class="ws-mine">${live.map((d) => html`<li class="ws-item">
++ <span class="inv-art inv-art-md r-${d.rarity}">${img(d)}</span>
++ <div class="ws-item-body">
++ <h2>${d.name}</h2>
++ <p class="muted">by ${d.credit && d.credit.name ? html`@${d.credit.name}` : d.issuer} · ${d.supply.issued} of ${d.supply.cap} given</p>
++ <form method="post" action="/workshop/${d.id}/takedown" class="ws-review">
++ <label class="ws-reason">Reason <input type="text" name="reason" maxlength="300" required placeholder="A rights holder asked us to remove it."></label>
++ <button type="submit" name="revoke" value="no" class="inv-btn">Retire</button>
++ <button type="submit" name="revoke" value="yes" class="inv-btn">Retire and revoke every copy</button>
++ </form>
++ </div>
++</li>`)}</ul>` : html`<p class="muted">No published community badges.</p>`}`,
});
});
++ r.post('/workshop/:id/takedown', express.urlencoded({ extended: false, limit: '2kb' }), async (req, res) => {
++ if (!signedIn(req)) return login(res, '/workshop/review');
++ if (!isStaff(req)) return res.status(403).type('text/plain').send('Staff only.');
++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('That form must come from this site.');
++ const b = req.body || {};
++ try {
++ const out = await inv.takedown(`user:${req.viewer.subject}`, String(req.params.id || ''), { revoke: b.revoke === 'yes', reason: b.reason ? String(b.reason) : '' });
++ return back(res, '/workshop/review', 'done', `${out.definition.name}: retired${b.revoke === 'yes' ? `, ${out.revoked} ${out.revoked === 1 ? 'copy' : 'copies'} revoked` : ''}.`);
++ } catch (err) {
++ if (err instanceof InventoryError) return back(res, '/workshop/review', 'error', err.detail || err.code);
++ throw err;
++ }
++ });
++
r.post('/workshop/:id/review', express.urlencoded({ extended: false, limit: '2kb' }), async (req, res) => {
if (!signedIn(req)) return login(res, '/workshop/review');
if (!isStaff(req)) return res.status(403).type('text/plain').send('Staff only.');
ddiff --git a/test/workshop.test.js b/test/workshop.test.js
iindex 57d4dd1..ad031d6 100644
---- a/test/workshop.test.js
++++ b/test/workshop.test.js
@@@ -140,6 +140,22 @@ function multipart(fields, file) {
const live = await inv.createDefinition(`user:${dave.subject}`, { kind: 'live.hat', name: 'Nope', art: { emoji: '🎩' }, rarity: 'common', attributes: { tier: 1 } }).catch((e) => e);
assert.strictEqual(live.code, 'inventory.not_issuer', 'a person defines only Workshop kinds');
});
++
++ await check('staff take a badge down: retired, and with revoke every copy is taken off its owner', async () => {
++ assert.strictEqual((await form(alice, `/workshop/${badge.id}/takedown`, { reason: 'mine', revoke: 'yes' })).status, 403, 'not for the creator');
++ let r = await form(staff, `/workshop/${badge.id}/takedown`, { reason: '', revoke: 'yes' });
++ assert.match(decodeURIComponent(r.headers.get('location')), /says why/);
++ r = await form(staff, `/workshop/${badge.id}/takedown`, { reason: 'A rights holder asked us to remove it.', revoke: 'yes' });
++ assert.match(decodeURIComponent(r.headers.get('location')), /retired, 1 copy revoked/);
++ assert.strictEqual((await inv.getDefinition(badge.id)).status, 'retired');
++ const mine = (await inv.inventory(carol.subject, { own: true })).instances.find((i) => i.definition_id === badge.id);
++ assert.strictEqual(mine.state, 'revoked');
++ const worn = (await t.get(`/api/v1/equipped?subjects=${carol.subject}`)).json().equipped[0].slots['network.badge:badge'];
++ assert.strictEqual(worn, undefined, 'taken off');
++ assert.ok(!(await t.get('/workshop')).text.includes('Night Owl'), 'gone from the Workshop');
++ const gift = await inv.grant(`user:${alice.subject}`, { definition_id: badge.id, subject: alice.subject, idempotency_key: 'ws:after-retire', origin: 'granted' }).catch((e) => e);
++ assert.strictEqual(gift.code, 'inventory.not_published', 'nobody gives it any more');
++ });
} finally {
await t.close();
}