openvibers/openvibe.inventory Commits
Inventory: a Workshop item's creator never publishes or retires it (staff review only); limits before the Media upload; resubmit a rejected badge (#12)
Changes
@@@ -164,10 +164,7 @@ function createInventory({ s, outbox, log = console }) {if (!b.art || typeof b.art !== 'object' || !/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(b.art.media_id || '')) || Object.keys(b.art).length !== 1) fail(422, 'inventory.bad_art', 'a Workshop item is one reviewed image: art is { media_id }');if (!(Number.isInteger(b.supply_cap) && b.supply_cap >= 1 && b.supply_cap <= WORKSHOP.maxCap)) fail(422, 'inventory.bad_supply', `a Workshop item has a cap from 1 to ${WORKSHOP.maxCap}`);if (b.grantors !== undefined || b.aliases !== undefined || b.publish) fail(422, 'request.bad_body', 'a Workshop item is submitted for review, with no grantors or aliases');-- const waiting = Number(await db.value("SELECT count(*) FROM inventory_definitions WHERE issuer = $1 AND status = 'in_review'", [requester]));-- if (waiting >= WORKSHOP.inReview) fail(429, 'inventory.review_queue_full', `at most ${WORKSHOP.inReview} items in review at once`);-- const today = Number(await db.value('SELECT count(*) FROM inventory_definitions WHERE issuer = $1 AND left(created_at, 10) = $2::text', [requester, s.iso().slice(0, 10)]));-- if (today >= WORKSHOP.perDay) fail(429, 'inventory.submissions_today', `at most ${WORKSHOP.perDay} submissions a day`);++ await checkWorkshopQuota(requester);b = { ...b, rarity: 'common' };}checkDefinitionBody(b);@@@ -195,9 +192,10 @@ function createInventory({ s, outbox, log = console }) {async function updateDefinition(requester, id, b) {if (!b || typeof b !== 'object') fail(400, 'request.bad_body', 'send the fields to change');return await s.tx(async () => {-- const cur = await db.maybe('SELECT * FROM inventory_definitions WHERE id = $1 FOR UPDATE', [String(id || '')]);++ 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) fail(404, 'inventory.definition_not_found', 'no such definition');if (cur.issuer !== requester) fail(403, 'inventory.not_issuer', 'only its issuer edits a definition');++ if (cur.workshop && PERSON_RE.test(requester)) return await updateWorkshopItem(requester, cur, b);checkDefinitionBody(b, { partial: true });const locked = cur.status === 'published' || cur.status === 'retired';for (const k of ['rarity', 'attributes', 'supply_cap', 'kind']) if (locked && b[k] !== undefined) fail(409, 'inventory.published', `${k} cannot change once the item is published (its owners rely on it)`);@@@ -223,6 +221,48 @@ function createInventory({ s, outbox, log = console }) {});}++ /**++ * The Workshop's submission limits for a person (at most WORKSHOP.inReview waiting, WORKSHOP.perDay a day). Throws++ * the same 429 the submission would; the submit page asks before it uploads the image, so a person over a limit++ * stores nothing in OpenVibe.Media.++ */++ async function checkWorkshopQuota(requester) {++ const waiting = Number(await db.value("SELECT count(*) FROM inventory_definitions WHERE issuer = $1 AND status = 'in_review'", [requester]));++ if (waiting >= WORKSHOP.inReview) fail(429, 'inventory.review_queue_full', `at most ${WORKSHOP.inReview} items in review at once`);++ const today = Number(await db.value('SELECT count(*) FROM inventory_definitions WHERE issuer = $1 AND left(created_at, 10) = $2::text', [requester, s.iso().slice(0, 10)]));++ if (today >= WORKSHOP.perDay) fail(429, 'inventory.submissions_today', `at most ${WORKSHOP.perDay} submissions a day`);++ }++++ /**++ * A Workshop item edited by its creator (ADR-054 §6). Staff review is the only way to publish and takedown the only++ * way to retire, so the creator never changes the status directly. They edit a draft (a rejected item), and++ * `publish: true` on a draft resubmits it for review, within the in-review cap. In review or published, the item++ * stays as reviewed: its name, words and image do not change.++ */++ async function updateWorkshopItem(requester, cur, b) {++ const allowed = ['name', 'description', 'art', 'publish'];++ const extra = Object.keys(b).filter((k) => !allowed.includes(k));++ if (extra.length) fail(422, 'request.bad_body', `a Workshop item's creator changes only its name, description and image (not ${extra.join(', ')})`);++ if (cur.status !== 'draft') fail(409, 'inventory.review_locked', `the item is ${cur.status}: staff publish and retire Workshop items, and a reviewed item does not change`);++ checkDefinitionBody(b, { partial: true });++ if (b.art !== undefined && (!b.art || typeof b.art !== 'object' || !/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(b.art.media_id || '')) || Object.keys(b.art).length !== 1)) {++ fail(422, 'inventory.bad_art', 'a Workshop item is one reviewed image: art is { media_id }');++ }++ let status = 'draft';++ if (b.publish) {++ const waiting = Number(await db.value("SELECT count(*) FROM inventory_definitions WHERE issuer = $1 AND status = 'in_review'", [requester]));++ if (waiting >= WORKSHOP.inReview) fail(429, 'inventory.review_queue_full', `at most ${WORKSHOP.inReview} items in review at once`);++ status = 'in_review';++ }++ const now = s.iso();++ await db.exec(`UPDATE inventory_definitions SET name = $2, description = $3, art = $4, status = $5, updated_at = $6,++ review_note = CASE WHEN $5 = 'in_review' THEN NULL ELSE review_note END 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), status, now]);++ await ledger(status === 'in_review' ? 'resubmitted' : 'edited', { definition: cur.id, actor: requester });++ return await getDefinition(cur.id);++ }++// ── Grants ────────────────────────────────────────────/*** One instance of a published definition the requester issued, to a person. → { instance, created }.@@@ -428,7 +468,7 @@ function createInventory({ s, outbox, log = console }) {return {listKinds, getKind, upsertKind, listDefinitions, getDefinition, byAlias, createDefinition, updateDefinition,-- grant, consume, inventory, equipped, equip, ledger, review, workshopItems, takedown,++ grant, consume, inventory, equipped, equip, ledger, review, workshopItems, takedown, checkWorkshopQuota,};}@@@ -113,6 +113,8 @@ ${notice(req)}if (pic.error) return back(res, '/workshop/new', 'error', pic.error);const cap = Number.parseInt(f.supply_cap, 10);try {++ // The limits first: a person over them stores nothing in OpenVibe.Media.++ await inv.checkWorkshopQuota(`user:${me}`);const mediaId = await workshopMedia.upload({ buffer: form.file.buffer, type: pic.type, name: f.name, ownerSubject: me });await inv.createDefinition(`user:${me}`, {kind: KIND, name: String(f.name || '').trim(), description: String(f.description || '').trim() || undefined,@@@ -142,6 +144,10 @@ ${mine.length ? html`<ul class="ws-mine">${mine.map((d) => html`<li class="ws-it<div class="ws-item-body"><h2>${d.name} <span class="ws-state ws-${d.status}">${STATUS_LABEL[d.status] || d.status}</span></h2>${d.status === 'draft' && d.review_note ? html`<p class="notice bad">Staff's reason: ${d.review_note}</p>` : ''}++ ${d.status === 'draft' ? html`<form method="post" action="/workshop/${d.id}/resubmit" class="ws-give">++ <label>Name <input type="text" name="name" required maxlength="64" value="${d.name}"></label>++ <button type="submit" class="inv-btn primary">Send for review again</button>++ </form>` : ''}${d.status === 'in_review' ? html`<p class="muted">Waiting for staff. Nobody else can see it yet.</p>` : ''}${d.status === 'published' ? html`<p class="muted">${d.supply.issued} of ${d.supply.cap} given · <a href="/items/${d.id}">its page</a></p>${d.supply.issued < d.supply.cap ? html`<form method="post" action="/workshop/${d.id}/give" class="ws-give">@@@ -170,6 +176,21 @@ ${mine.length ? html`<ul class="ws-mine">${mine.map((d) => html`<li class="ws-it}});++ // A rejected badge goes back to staff: its creator may rename it first (the image stays the one they uploaded).++ r.post('/workshop/:id/resubmit', express.urlencoded({ extended: false, limit: '2kb' }), async (req, res) => {++ const me = signedIn(req);++ if (!me) return login(res, '/workshop/mine');++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('That form must come from this site.');++ const name = String((req.body || {}).name || '').trim();++ try {++ await inv.updateDefinition(`user:${me}`, String(req.params.id || ''), { ...(name ? { name } : {}), publish: true });++ return back(res, '/workshop/mine', 'done', 'Sent for review again.');++ } catch (err) {++ if (err instanceof InventoryError) return back(res, '/workshop/mine', 'error', err.detail || err.code);++ throw err;++ }++ });++// ── Staff review ─────────────────────────────────────────r.get('/workshop/review', async (req, res) => {if (!signedIn(req)) return login(res, '/workshop/review');@@@ -104,6 +104,31 @@ function multipart(fields, file) {assert.match((await t.get('/workshop/mine', { as: alice })).text, /Staff('|'|')s reason: Too close to a channel logo\./);});++ await check('the creator never publishes or retires: a rejected draft is edited and resubmitted for review, and a reviewed item is locked', async () => {++ const patch = (json) => t.get(`/api/v1/definitions/${first.id}`, { as: alice, method: 'PATCH', json, headers: origin });++ let r = await patch({ name: 'OG Viewer II', publish: true });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().definition.status, 'in_review', 'publish on a draft resubmits it, it never publishes');++ assert.strictEqual((await inv.getDefinition(first.id)).name, 'OG Viewer II');++ r = await patch({ publish: true });++ assert.deepStrictEqual([r.status, r.json().code], [409, 'inventory.review_locked'], 'in review: locked');++ r = await patch({ status: 'retired' });++ assert.deepStrictEqual([r.status, r.json().code], [422, 'request.bad_body'], 'the creator never retires (takedown is staff)');++ r = await patch({ rarity: 'legendary' });++ assert.strictEqual(r.status, 422, 'nor sets a rarity');++ assert.strictEqual((await inv.getDefinition(first.id)).status, 'in_review');++ await form(staff, `/workshop/${first.id}/review`, { decision: 'reject', reason: 'Still too close.' });++ assert.strictEqual((await inv.getDefinition(first.id)).status, 'draft');++ // The page's button resubmits a draft (renamed), and only its creator's.++ assert.match((await t.get('/workshop/mine', { as: alice })).text, /Send for review again/);++ assert.match((await form(carol, `/workshop/${first.id}/resubmit`, { name: 'Mine now' })).headers.get('location') || '', /error=/);++ r = await form(alice, `/workshop/${first.id}/resubmit`, { name: 'OG Viewer III' });++ assert.match(r.headers.get('location') || '', /done=/);++ const back = await inv.getDefinition(first.id);++ assert.deepStrictEqual([back.status, back.name], ['in_review', 'OG Viewer III']);++ await form(staff, `/workshop/${first.id}/review`, { decision: 'reject', reason: 'Still too close.' });++ });++let badge;await check('a published badge is public, given by @name within its cap, and worn with its image', async () => {await submit(alice, { name: 'Night Owl', supply_cap: '2', rights: 'yes' }, webp(256, 256));@@@ -133,6 +158,10 @@ function multipart(fields, file) {for (let i = 0; i < 5; i++) await inv.createDefinition(`user:${dave.subject}`, { kind: 'network.badge', name: `B${i}`, art: { media_id: 'med_01JZ0000000000000000000099' }, rarity: 'common', attributes: {}, supply_cap: 10 });const sixth = await inv.createDefinition(`user:${dave.subject}`, { kind: 'network.badge', name: 'B6', art: { media_id: 'med_01JZ0000000000000000000099' }, rarity: 'common', attributes: {}, supply_cap: 10 }).catch((e) => e);assert.strictEqual(sixth.code, 'inventory.review_queue_full');++ const stored = uploads.length;++ const over = await submit(dave, { name: 'B7', supply_cap: '5', rights: 'yes' }, png(64, 64));++ assert.match(over.headers.get('location') || '', /error=/);++ assert.strictEqual(uploads.length, stored, 'over the limit, the image is never uploaded to Media');const notStaff = await t.get(`/api/v1/definitions/${badge.id}/review`, { as: alice, method: 'POST', json: { decision: 'publish' }, headers: origin });assert.deepStrictEqual([notStaff.status, notStaff.json().code], [403, 'inventory.staff_only']);const earned = await inv.grant(`user:${alice.subject}`, { definition_id: badge.id, subject: dave.subject, idempotency_key: 'ws:earned-0001', origin: 'earned' }).catch((e) => e);
@@ -164,10 +164,7 @@ function createInventory({ s, outbox, log = console }) {
@@ -164,10 +164,7 @@ function createInventory({ s, outbox, log = console }) {
if (!b.art || typeof b.art !== 'object' || !/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(b.art.media_id || '')) || Object.keys(b.art).length !== 1) fail(422, 'inventory.bad_art', 'a Workshop item is one reviewed image: art is { media_id }');
if (!b.art || typeof b.art !== 'object' || !/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(b.art.media_id || '')) || Object.keys(b.art).length !== 1) fail(422, 'inventory.bad_art', 'a Workshop item is one reviewed image: art is { media_id }');
if (!(Number.isInteger(b.supply_cap) && b.supply_cap >= 1 && b.supply_cap <= WORKSHOP.maxCap)) fail(422, 'inventory.bad_supply', `a Workshop item has a cap from 1 to ${WORKSHOP.maxCap}`);
if (!(Number.isInteger(b.supply_cap) && b.supply_cap >= 1 && b.supply_cap <= WORKSHOP.maxCap)) fail(422, 'inventory.bad_supply', `a Workshop item has a cap from 1 to ${WORKSHOP.maxCap}`);
if (b.grantors !== undefined || b.aliases !== undefined || b.publish) fail(422, 'request.bad_body', 'a Workshop item is submitted for review, with no grantors or aliases');
if (b.grantors !== undefined || b.aliases !== undefined || b.publish) fail(422, 'request.bad_body', 'a Workshop item is submitted for review, with no grantors or aliases');
- const waiting = Number(await db.value("SELECT count(*) FROM inventory_definitions WHERE issuer = $1 AND status = 'in_review'", [requester]));
- if (waiting >= WORKSHOP.inReview) fail(429, 'inventory.review_queue_full', `at most ${WORKSHOP.inReview} items in review at once`);
- const today = Number(await db.value('SELECT count(*) FROM inventory_definitions WHERE issuer = $1 AND left(created_at, 10) = $2::text', [requester, s.iso().slice(0, 10)]));
- if (today >= WORKSHOP.perDay) fail(429, 'inventory.submissions_today', `at most ${WORKSHOP.perDay} submissions a day`);
+ await checkWorkshopQuota(requester);
b = { ...b, rarity: 'common' };
b = { ...b, rarity: 'common' };
}
}
checkDefinitionBody(b);
checkDefinitionBody(b);
@@ -195,9 +192,10 @@ function createInventory({ s, outbox, log = console }) {
@@ -195,9 +192,10 @@ function createInventory({ s, outbox, log = console }) {
async function updateDefinition(requester, id, b) {
async function updateDefinition(requester, id, b) {
if (!b || typeof b !== 'object') fail(400, 'request.bad_body', 'send the fields to change');
if (!b || typeof b !== 'object') fail(400, 'request.bad_body', 'send the fields to change');
return await s.tx(async () => {
return await s.tx(async () => {
- const cur = await db.maybe('SELECT * FROM inventory_definitions WHERE id = $1 FOR UPDATE', [String(id || '')]);
+ 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) fail(404, 'inventory.definition_not_found', 'no such definition');
if (!cur) fail(404, 'inventory.definition_not_found', 'no such definition');
if (cur.issuer !== requester) fail(403, 'inventory.not_issuer', 'only its issuer edits a definition');
if (cur.issuer !== requester) fail(403, 'inventory.not_issuer', 'only its issuer edits a definition');
+ if (cur.workshop && PERSON_RE.test(requester)) return await updateWorkshopItem(requester, cur, b);
checkDefinitionBody(b, { partial: true });
checkDefinitionBody(b, { partial: true });
const locked = cur.status === 'published' || cur.status === 'retired';
const locked = cur.status === 'published' || cur.status === 'retired';
for (const k of ['rarity', 'attributes', 'supply_cap', 'kind']) if (locked && b[k] !== undefined) fail(409, 'inventory.published', `${k} cannot change once the item is published (its owners rely on it)`);
for (const k of ['rarity', 'attributes', 'supply_cap', 'kind']) if (locked && b[k] !== undefined) fail(409, 'inventory.published', `${k} cannot change once the item is published (its owners rely on it)`);
@@ -223,6 +221,48 @@ function createInventory({ s, outbox, log = console }) {
@@ -223,6 +221,48 @@ function createInventory({ s, outbox, log = console }) {
});
});
}
}
+ /**
+ * The Workshop's submission limits for a person (at most WORKSHOP.inReview waiting, WORKSHOP.perDay a day). Throws
+ * the same 429 the submission would; the submit page asks before it uploads the image, so a person over a limit
+ * stores nothing in OpenVibe.Media.
+ */
+ async function checkWorkshopQuota(requester) {
+ const waiting = Number(await db.value("SELECT count(*) FROM inventory_definitions WHERE issuer = $1 AND status = 'in_review'", [requester]));
+ if (waiting >= WORKSHOP.inReview) fail(429, 'inventory.review_queue_full', `at most ${WORKSHOP.inReview} items in review at once`);
+ const today = Number(await db.value('SELECT count(*) FROM inventory_definitions WHERE issuer = $1 AND left(created_at, 10) = $2::text', [requester, s.iso().slice(0, 10)]));
+ if (today >= WORKSHOP.perDay) fail(429, 'inventory.submissions_today', `at most ${WORKSHOP.perDay} submissions a day`);
+ }
+
+ /**
+ * A Workshop item edited by its creator (ADR-054 §6). Staff review is the only way to publish and takedown the only
+ * way to retire, so the creator never changes the status directly. They edit a draft (a rejected item), and
+ * `publish: true` on a draft resubmits it for review, within the in-review cap. In review or published, the item
+ * stays as reviewed: its name, words and image do not change.
+ */
+ async function updateWorkshopItem(requester, cur, b) {
+ const allowed = ['name', 'description', 'art', 'publish'];
+ const extra = Object.keys(b).filter((k) => !allowed.includes(k));
+ if (extra.length) fail(422, 'request.bad_body', `a Workshop item's creator changes only its name, description and image (not ${extra.join(', ')})`);
+ if (cur.status !== 'draft') fail(409, 'inventory.review_locked', `the item is ${cur.status}: staff publish and retire Workshop items, and a reviewed item does not change`);
+ checkDefinitionBody(b, { partial: true });
+ if (b.art !== undefined && (!b.art || typeof b.art !== 'object' || !/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(b.art.media_id || '')) || Object.keys(b.art).length !== 1)) {
+ fail(422, 'inventory.bad_art', 'a Workshop item is one reviewed image: art is { media_id }');
+ }
+ let status = 'draft';
+ if (b.publish) {
+ const waiting = Number(await db.value("SELECT count(*) FROM inventory_definitions WHERE issuer = $1 AND status = 'in_review'", [requester]));
+ if (waiting >= WORKSHOP.inReview) fail(429, 'inventory.review_queue_full', `at most ${WORKSHOP.inReview} items in review at once`);
+ status = 'in_review';
+ }
+ const now = s.iso();
+ await db.exec(`UPDATE inventory_definitions SET name = $2, description = $3, art = $4, status = $5, updated_at = $6,
+ review_note = CASE WHEN $5 = 'in_review' THEN NULL ELSE review_note END 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), status, now]);
+ await ledger(status === 'in_review' ? 'resubmitted' : 'edited', { definition: cur.id, actor: requester });
+ return await getDefinition(cur.id);
+ }
+
// ── Grants ────────────────────────────────────────────
// ── Grants ────────────────────────────────────────────
/**
/**
* One instance of a published definition the requester issued, to a person. → { instance, created }.
* One instance of a published definition the requester issued, to a person. → { instance, created }.
@@ -428,7 +468,7 @@ function createInventory({ s, outbox, log = console }) {
@@ -428,7 +468,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, takedown,
+ grant, consume, inventory, equipped, equip, ledger, review, workshopItems, takedown, checkWorkshopQuota,
};
};
}
}
@@ -113,6 +113,8 @@ ${notice(req)}
@@ -113,6 +113,8 @@ ${notice(req)}
if (pic.error) return back(res, '/workshop/new', 'error', pic.error);
if (pic.error) return back(res, '/workshop/new', 'error', pic.error);
const cap = Number.parseInt(f.supply_cap, 10);
const cap = Number.parseInt(f.supply_cap, 10);
try {
try {
+ // The limits first: a person over them stores nothing in OpenVibe.Media.
+ await inv.checkWorkshopQuota(`user:${me}`);
const mediaId = await workshopMedia.upload({ buffer: form.file.buffer, type: pic.type, name: f.name, ownerSubject: me });
const mediaId = await workshopMedia.upload({ buffer: form.file.buffer, type: pic.type, name: f.name, ownerSubject: me });
await inv.createDefinition(`user:${me}`, {
await inv.createDefinition(`user:${me}`, {
kind: KIND, name: String(f.name || '').trim(), description: String(f.description || '').trim() || undefined,
kind: KIND, name: String(f.name || '').trim(), description: String(f.description || '').trim() || undefined,
@@ -142,6 +144,10 @@ ${mine.length ? html`<ul class="ws-mine">${mine.map((d) => html`<li class="ws-it
@@ -142,6 +144,10 @@ ${mine.length ? html`<ul class="ws-mine">${mine.map((d) => html`<li class="ws-it
<div class="ws-item-body">
<div class="ws-item-body">
<h2>${d.name} <span class="ws-state ws-${d.status}">${STATUS_LABEL[d.status] || d.status}</span></h2>
<h2>${d.name} <span class="ws-state ws-${d.status}">${STATUS_LABEL[d.status] || d.status}</span></h2>
${d.status === 'draft' && d.review_note ? html`<p class="notice bad">Staff's reason: ${d.review_note}</p>` : ''}
${d.status === 'draft' && d.review_note ? html`<p class="notice bad">Staff's reason: ${d.review_note}</p>` : ''}
+ ${d.status === 'draft' ? html`<form method="post" action="/workshop/${d.id}/resubmit" class="ws-give">
+ <label>Name <input type="text" name="name" required maxlength="64" value="${d.name}"></label>
+ <button type="submit" class="inv-btn primary">Send for review again</button>
+ </form>` : ''}
${d.status === 'in_review' ? html`<p class="muted">Waiting for staff. Nobody else can see it yet.</p>` : ''}
${d.status === 'in_review' ? html`<p class="muted">Waiting for staff. Nobody else can see it yet.</p>` : ''}
${d.status === 'published' ? html`<p class="muted">${d.supply.issued} of ${d.supply.cap} given · <a href="/items/${d.id}">its page</a></p>
${d.status === 'published' ? html`<p class="muted">${d.supply.issued} of ${d.supply.cap} given · <a href="/items/${d.id}">its page</a></p>
${d.supply.issued < d.supply.cap ? html`<form method="post" action="/workshop/${d.id}/give" class="ws-give">
${d.supply.issued < d.supply.cap ? html`<form method="post" action="/workshop/${d.id}/give" class="ws-give">
@@ -170,6 +176,21 @@ ${mine.length ? html`<ul class="ws-mine">${mine.map((d) => html`<li class="ws-it
@@ -170,6 +176,21 @@ ${mine.length ? html`<ul class="ws-mine">${mine.map((d) => html`<li class="ws-it
}
}
});
});
+ // A rejected badge goes back to staff: its creator may rename it first (the image stays the one they uploaded).
+ r.post('/workshop/:id/resubmit', express.urlencoded({ extended: false, limit: '2kb' }), async (req, res) => {
+ const me = signedIn(req);
+ if (!me) return login(res, '/workshop/mine');
+ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('That form must come from this site.');
+ const name = String((req.body || {}).name || '').trim();
+ try {
+ await inv.updateDefinition(`user:${me}`, String(req.params.id || ''), { ...(name ? { name } : {}), publish: true });
+ return back(res, '/workshop/mine', 'done', 'Sent for review again.');
+ } catch (err) {
+ if (err instanceof InventoryError) return back(res, '/workshop/mine', 'error', err.detail || err.code);
+ throw err;
+ }
+ });
+
// ── Staff review ─────────────────────────────────────────
// ── Staff review ─────────────────────────────────────────
r.get('/workshop/review', async (req, res) => {
r.get('/workshop/review', async (req, res) => {
if (!signedIn(req)) return login(res, '/workshop/review');
if (!signedIn(req)) return login(res, '/workshop/review');
@@ -104,6 +104,31 @@ function multipart(fields, file) {
@@ -104,6 +104,31 @@ function multipart(fields, file) {
assert.match((await t.get('/workshop/mine', { as: alice })).text, /Staff('|'|')s reason: Too close to a channel logo\./);
assert.match((await t.get('/workshop/mine', { as: alice })).text, /Staff('|'|')s reason: Too close to a channel logo\./);
});
});
+ await check('the creator never publishes or retires: a rejected draft is edited and resubmitted for review, and a reviewed item is locked', async () => {
+ const patch = (json) => t.get(`/api/v1/definitions/${first.id}`, { as: alice, method: 'PATCH', json, headers: origin });
+ let r = await patch({ name: 'OG Viewer II', publish: true });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().definition.status, 'in_review', 'publish on a draft resubmits it, it never publishes');
+ assert.strictEqual((await inv.getDefinition(first.id)).name, 'OG Viewer II');
+ r = await patch({ publish: true });
+ assert.deepStrictEqual([r.status, r.json().code], [409, 'inventory.review_locked'], 'in review: locked');
+ r = await patch({ status: 'retired' });
+ assert.deepStrictEqual([r.status, r.json().code], [422, 'request.bad_body'], 'the creator never retires (takedown is staff)');
+ r = await patch({ rarity: 'legendary' });
+ assert.strictEqual(r.status, 422, 'nor sets a rarity');
+ assert.strictEqual((await inv.getDefinition(first.id)).status, 'in_review');
+ await form(staff, `/workshop/${first.id}/review`, { decision: 'reject', reason: 'Still too close.' });
+ assert.strictEqual((await inv.getDefinition(first.id)).status, 'draft');
+ // The page's button resubmits a draft (renamed), and only its creator's.
+ assert.match((await t.get('/workshop/mine', { as: alice })).text, /Send for review again/);
+ assert.match((await form(carol, `/workshop/${first.id}/resubmit`, { name: 'Mine now' })).headers.get('location') || '', /error=/);
+ r = await form(alice, `/workshop/${first.id}/resubmit`, { name: 'OG Viewer III' });
+ assert.match(r.headers.get('location') || '', /done=/);
+ const back = await inv.getDefinition(first.id);
+ assert.deepStrictEqual([back.status, back.name], ['in_review', 'OG Viewer III']);
+ await form(staff, `/workshop/${first.id}/review`, { decision: 'reject', reason: 'Still too close.' });
+ });
+
let badge;
let badge;
await check('a published badge is public, given by @name within its cap, and worn with its image', async () => {
await check('a published badge is public, given by @name within its cap, and worn with its image', async () => {
await submit(alice, { name: 'Night Owl', supply_cap: '2', rights: 'yes' }, webp(256, 256));
await submit(alice, { name: 'Night Owl', supply_cap: '2', rights: 'yes' }, webp(256, 256));
@@ -133,6 +158,10 @@ function multipart(fields, file) {
@@ -133,6 +158,10 @@ function multipart(fields, file) {
for (let i = 0; i < 5; i++) await inv.createDefinition(`user:${dave.subject}`, { kind: 'network.badge', name: `B${i}`, art: { media_id: 'med_01JZ0000000000000000000099' }, rarity: 'common', attributes: {}, supply_cap: 10 });
for (let i = 0; i < 5; i++) await inv.createDefinition(`user:${dave.subject}`, { kind: 'network.badge', name: `B${i}`, art: { media_id: 'med_01JZ0000000000000000000099' }, rarity: 'common', attributes: {}, supply_cap: 10 });
const sixth = await inv.createDefinition(`user:${dave.subject}`, { kind: 'network.badge', name: 'B6', art: { media_id: 'med_01JZ0000000000000000000099' }, rarity: 'common', attributes: {}, supply_cap: 10 }).catch((e) => e);
const sixth = await inv.createDefinition(`user:${dave.subject}`, { kind: 'network.badge', name: 'B6', art: { media_id: 'med_01JZ0000000000000000000099' }, rarity: 'common', attributes: {}, supply_cap: 10 }).catch((e) => e);
assert.strictEqual(sixth.code, 'inventory.review_queue_full');
assert.strictEqual(sixth.code, 'inventory.review_queue_full');
+ const stored = uploads.length;
+ const over = await submit(dave, { name: 'B7', supply_cap: '5', rights: 'yes' }, png(64, 64));
+ assert.match(over.headers.get('location') || '', /error=/);
+ assert.strictEqual(uploads.length, stored, 'over the limit, the image is never uploaded to Media');
const notStaff = await t.get(`/api/v1/definitions/${badge.id}/review`, { as: alice, method: 'POST', json: { decision: 'publish' }, headers: origin });
const notStaff = await t.get(`/api/v1/definitions/${badge.id}/review`, { as: alice, method: 'POST', json: { decision: 'publish' }, headers: origin });
assert.deepStrictEqual([notStaff.status, notStaff.json().code], [403, 'inventory.staff_only']);
assert.deepStrictEqual([notStaff.status, notStaff.json().code], [403, 'inventory.staff_only']);
const earned = await inv.grant(`user:${alice.subject}`, { definition_id: badge.id, subject: dave.subject, idempotency_key: 'ws:earned-0001', origin: 'earned' }).catch((e) => e);
const earned = await inv.grant(`user:${alice.subject}`, { definition_id: badge.id, subject: dave.subject, idempotency_key: 'ws:earned-0001', origin: 'earned' }).catch((e) => e);