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)
Changes
@@@ -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.@@@ -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",@@@ -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",@@@ -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,};}@@@ -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-itif (!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.');@@@ -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();}
@@ -2,6 +2,12 @@
@@ -2,6 +2,12 @@
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.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
## 0.4.0 — 2026-10-09
- **The Workshop** (ADR-054 §6 amendment, openvibe-contracts 0.124.0): community badges, free.
- **The Workshop** (ADR-054 §6 amendment, openvibe-contracts 0.124.0): community badges, free.
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-inventory",
"name": "openvibe-inventory",
- "version": "0.4.0",
+ "version": "0.4.1",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-inventory",
"name": "openvibe-inventory",
- "version": "0.4.0",
+ "version": "0.4.1",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.20.0",
"ajv": "^8.20.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-inventory",
"name": "openvibe-inventory",
- "version": "0.4.0",
+ "version": "0.4.1",
"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",
@@ -388,6 +388,35 @@ function createInventory({ s, outbox, log = console }) {
@@ -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. */
/** 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 } = {}) {
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
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 }) {
@@ -399,7 +428,7 @@ function createInventory({ s, outbox, log = console }) {
return {
return {
listKinds, getKind, upsertKind, listDefinitions, getDefinition, byAlias, createDefinition, updateDefinition,
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,
};
};
}
}
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
* /workshop what it is, the published community badges, how to make one
* /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/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/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
* 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).
* 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
@@ -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 (!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);
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 queue = await inv.workshopItems({ status: 'in_review', limit: 100 });
+ const live = await inv.workshopItems({ status: 'published', limit: 200 });
page(req, res, {
page(req, res, {
title: 'Workshop review',
title: 'Workshop review',
robots: 'noindex',
robots: 'noindex',
@@ -194,10 +195,38 @@ ${queue.length ? html`<ul class="ws-mine">${queue.map((d) => html`<li class="ws-
@@ -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>
<button type="submit" name="decision" value="reject" class="inv-btn">Reject</button>
</form>
</form>
</div>
</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) => {
r.post('/workshop/:id/review', express.urlencoded({ extended: false, limit: '2kb' }), async (req, res) => {
if (!signedIn(req)) return login(res, '/workshop/review');
if (!signedIn(req)) return login(res, '/workshop/review');
if (!isStaff(req)) return res.status(403).type('text/plain').send('Staff only.');
if (!isStaff(req)) return res.status(403).type('text/plain').send('Staff only.');
@@ -140,6 +140,22 @@ function multipart(fields, file) {
@@ -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);
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');
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 {
} finally {
await t.close();
await t.close();
}
}