openvibers/openvibe.inventory Commits

Inventory 0.4.0: the Workshop (ADR-054 §6, contracts 0.124.0) — community badges made by anyone, reviewed by staff, given free by their makers (#8)

OP OpenVibers committed 9 Oct 2026 · 53d22a3b11 · 25 files, +762 −34

Changes

ddiff --git a/.env.example b/.env.example
iindex bc1cece..a6c68cc 100644
---- a/.env.example
++++ b/.env.example
@@@ -34,3 +34,8 @@ OV_OAUTH_CLIENT_SECRET=
#EVENTS_URL=http://127.0.0.1:4300
#EVENTS_RELAY_INTERVAL_MS=2000
#INVENTORY_EVENTS_SECRET=
++
++# The Workshop's images (server/workshop/media.js): this service's own tenant in OpenVibe.Media (MEDIA_APPS_SEED there
++# lists app_id inventory with this key). Without it, the Workshop says image uploads are not set up yet.
++#OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
++#INVENTORY_MEDIA_APP_KEY=
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 546d60d..5d57278 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,18 @@
What changed in OpenVibe.Inventory, newest first. Each site also publishes its patch notes at /updates.
++## 0.4.0 — 2026-10-09
++
++- **The Workshop** (ADR-054 §6 amendment, openvibe-contracts 0.124.0): community badges, free.
++ - **The kind:** `network.badge`, the first Workshop kind (`migrations/0003_workshop.sql`).
++ - **Making one:** `/workshop/new` takes the image as a no-JavaScript multipart upload, checked by its header bytes
++ and stored in OpenVibe.Media (`INVENTORY_MEDIA_APP_KEY`).
++ - **Review:** staff review at `/workshop/review` and `POST /api/v1/definitions/:id/review` (publish with a rarity, or
++ reject with a reason).
++ - **Giving:** creators give their badges by `@name` from `/workshop/mine`, through the Network's username lookup.
++ - **Rendering:** the equipped read carries `media_id`.
++ - **Account data:** export includes `made.json`; on deletion a creator's badges pass to the kind's issuer, retired.
++
## 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
ddiff --git a/README.md b/README.md
iindex ce08dfd..2d50e3e 100644
---- a/README.md
++++ b/README.md
@@@ -60,6 +60,18 @@ token that is presented must hold `inventory.item.read`.
items, equipped set and history. The deletion removes their items and equipped set and keeps the ledger rows without
them, so supply counts stay true.
++## The Workshop (ADR-054 §6)
++
++Community badges, free. A signed-in person makes one at `/workshop/new`: a square PNG or WebP image (64 to 512 px, at
++most 200 KB, checked by its own bytes and stored in OpenVibe.Media under this service's tenant), a name and a cap of
++at most 10 000. They also confirm that they made the image or may use it. It is `in_review`, credited to them and
++shown to nobody else until staff publish it at `/workshop/review` (or `POST /api/v1/definitions/:id/review`), with a
++rarity, or reject it with a reason the creator reads on `/workshop/mine`. A published badge is given by its creator,
++by `@name`, as `granted`, within its cap and 100 gifts a day. People wear it before their name in chat and on their
++profile (`network.badge.image@1`; the equipped read carries the image's `media_id`). Limits: 5 in review and 20
++submissions a day per person. On account deletion a creator's badges pass to the kind's issuer, retired, without
++them.
++
## Pages
`/` (what it is), `/items` (every item by kind), `/items/:id` (one item: where it shows, who gives it, how many exist),
@@@ -123,7 +135,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.123.0
++- openvibe-contracts: v0.124.0
- openvibe-sdk: v0.37.0
- openvibe-shared: v2.17.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 5b42159..cefae40 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.123.0",
++ "contracts": "openvibe-contracts v0.124.0",
"sdk": "openvibe-sdk v0.37.0",
"shared": "openvibe-shared v2.17.0",
"packages": [
-- "openvibe-contracts v0.123.0",
++ "openvibe-contracts v0.124.0",
"openvibe-sdk v0.37.0",
"openvibe-shared v2.17.0"
],
ddiff --git a/migrations/0003_workshop.sql b/migrations/0003_workshop.sql
nnew file mode 100644
iindex 0000000..bad71bc
---- /dev/null
++++ b/migrations/0003_workshop.sql
@@@ -0,0 +1,9 @@
++-- phase: expand
++-- The Workshop v1 (ADR-054 §6 amendment, Contracts 0.124.0): a kind may be a Workshop kind, whose definitions any
++-- person may submit (issued by and credited to them, in review until staff publish them). A rejected submission goes
++-- back to draft with the reviewer's note, which only its creator sees. Additive.
++ALTER TABLE inventory_kinds ADD COLUMN IF NOT EXISTS workshop boolean NOT NULL DEFAULT false;
++ALTER TABLE inventory_definitions ADD COLUMN IF NOT EXISTS review_note text;
++ALTER TABLE inventory_definitions ADD COLUMN IF NOT EXISTS reviewed_by text COLLATE "C";
++ALTER TABLE inventory_definitions ADD COLUMN IF NOT EXISTS reviewed_at text COLLATE "C";
++CREATE INDEX IF NOT EXISTS inventory_definitions_review ON inventory_definitions (status, created_at) WHERE status = 'in_review';
ddiff --git a/package-lock.json b/package-lock.json
iindex fa9771d..baf29c3 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,22 +1,23 @@
{
"name": "openvibe-inventory",
-- "version": "0.3.0",
++ "version": "0.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-inventory",
-- "version": "0.3.0",
++ "version": "0.4.0",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.20.0",
++ "busboy": "^1.6.0",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"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.123.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.124.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"
@@@ -123,6 +124,17 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
++ "node_modules/busboy": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
++ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
++ "dependencies": {
++ "streamsearch": "^1.1.0"
++ },
++ "engines": {
++ "node": ">=10.16.0"
++ }
++ },
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@@ -872,9 +884,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.123.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.123.0",
-- "integrity": "sha512-aO9o9qADB5wQ6XISPprGZ43P0eEIn/4aQmNVECe6YkDs4NFYvZD8TVPAzrmRk2tWFyIzaYYo3MTxy6ku71y6+w==",
++ "version": "0.124.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.124.0",
++ "integrity": "sha512-UHcQAaV1F6YB9rvuotYlyv0+RKuA6Gios81EDQ8aExSK7jZBDN5PG7teHwrDv4fvMFEaIOIniH4aWA9MomqFpg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -1343,6 +1355,14 @@
"node": ">= 0.8"
}
},
++ "node_modules/streamsearch": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
++ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
++ "engines": {
++ "node": ">=10.0.0"
++ }
++ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
ddiff --git a/package.json b/package.json
iindex 422921b..6dee53d 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-inventory",
-- "version": "0.3.0",
++ "version": "0.4.0",
"private": true,
"description": "OpenVibe.Inventory — Your items, on every OpenVibe site.",
"main": "server/index.js",
@@@ -17,13 +17,14 @@
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.20.0",
++ "busboy": "^1.6.0",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"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.123.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.124.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/public/css/app.css b/public/css/app.css
iindex 45a802b..00c51eb 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -101,3 +101,24 @@ button.sc-btn.sc-primary { border: 0; }
.inv-btn.primary:hover { filter: brightness(1.08); }
.inv-equip .inv-worn { margin-right: auto; }
.inv-card.is-worn { border-color: color-mix(in srgb, var(--success, #22c55e) 55%, var(--border, #1f2d47)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--success, #22c55e) 30%, transparent); }
++
++/* The Workshop (ADR-054 §6): community badges, your submissions, the staff review queue. */
++.ws-steps { padding-left: 1.2rem; line-height: 1.6; }
++.ws-steps li { margin: 0 0 6px; }
++.ws-img { width: 100%; height: 100%; object-fit: contain; image-rendering: auto; }
++.ws-form { display: grid; gap: 12px; max-width: 560px; }
++.ws-form label { display: grid; gap: 4px; font-weight: 600; }
++.ws-form input[type=text], .ws-form input[type=number], .ws-form textarea, .ws-give input, .ws-review input, .ws-review select { font: inherit; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #101828); color: inherit; }
++.ws-form input, .ws-form textarea { font-weight: 400; }
++.ws-check { display: flex !important; gap: 8px; align-items: flex-start; font-weight: 400 !important; }
++.ws-mine { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; }
++.ws-item { display: flex; gap: 14px; align-items: flex-start; padding: 14px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-secondary, #101828); }
++.ws-item-body { flex: 1; min-width: 0; }
++.ws-item-body h2 { margin: 0 0 6px; font-size: 1.05rem; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
++.ws-state { font-size: .72rem; font-weight: 700; padding: .1rem .5rem; border-radius: 999px; border: 1px solid var(--border, #1f2d47); }
++.ws-published { color: #22c55e; border-color: color-mix(in srgb, #22c55e 50%, transparent); }
++.ws-in_review { color: #f59e0b; border-color: color-mix(in srgb, #f59e0b 50%, transparent); }
++.ws-draft { color: #ef4444; border-color: color-mix(in srgb, #ef4444 50%, transparent); }
++.ws-give, .ws-review { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; margin-top: 8px; }
++.ws-give label, .ws-review label { display: grid; gap: 4px; font-size: .85rem; }
++.ws-reason { flex: 1; min-width: 200px; }
ddiff --git a/server/app.js b/server/app.js
iindex a6fda7a..4bdfea2 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -28,6 +28,9 @@ const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createApi } = require('./http/api');
const { createPageRoutes } = require('./http/pages');
++const { createWorkshopRoutes } = require('./workshop/pages');
++const { createWorkshopMedia } = require('./workshop/media');
++const { createPeople } = require('./workshop/people');
const { createServiceReadiness } = require('./observability');
const { createCallerLimits } = require('./http/caller-limits');
const { createServiceOutbox } = require('openvibe-sdk/events');
@@@ -70,6 +73,9 @@ async function createApp(opts = {}) {
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Inventory cannot answer account events'); });
const ctx = { config, s, keys, sso, principal, log, outbox, inv, accountData };
++ // The Workshop (ADR-054 §6): images in this service's own Media tenant, recipients by @name through the Network.
++ ctx.workshopMedia = opts.workshopMedia || createWorkshopMedia({ config, fetchImpl: opts.fetchImpl, log });
++ ctx.people = opts.people || createPeople({ config, fetchImpl: opts.fetchImpl });
const app = express();
app.disable('x-powered-by');
@@@ -159,6 +165,7 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.INVENTORY_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
++ app.use(createWorkshopRoutes(ctx));
app.use(createPageRoutes(ctx));
app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/">the home page</a> or <a href="/updates">the update log</a>.</p>` }));
ddiff --git a/server/config.js b/server/config.js
iindex 0ed85f0..b66268c 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -63,6 +63,14 @@ function load(env = process.env) {
intervalMs: Math.max(50, int(env.EVENTS_RELAY_INTERVAL_MS, 2000)),
secrets: String(env.INVENTORY_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
},
++ // The Workshop's images (server/workshop/media.js): this service's own tenant in OpenVibe.Media. Without the
++ // key, submitting an image says uploads are not set up yet; everything else works.
++ media: {
++ url: trim(env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100'),
++ app: env.INVENTORY_MEDIA_APP || 'inventory',
++ appKey: env.INVENTORY_MEDIA_APP_KEY || '',
++ timeoutMs: Math.max(1000, int(env.INVENTORY_MEDIA_TIMEOUT_MS, 15000)),
++ },
};
}
ddiff --git a/server/data/kinds.json b/server/data/kinds.json
iindex 4578beb..d67cff2 100644
---- a/server/data/kinds.json
++++ b/server/data/kinds.json
@@@ -206,6 +206,32 @@
},
"stackable": false,
"issuer": "service:live"
++ },
++ {
++ "id": "network.badge",
++ "name": "Badge",
++ "description": "A small image made by someone in the community, shown just before a person's name. Every one is reviewed by staff before anyone sees it.",
++ "slots": [
++ "badge"
++ ],
++ "surfaces": [
++ {
++ "surface": "chat",
++ "renderer": "network.badge.image@1"
++ },
++ {
++ "surface": "profile",
++ "renderer": "network.badge.image@1"
++ }
++ ],
++ "attribute_schema": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {}
++ },
++ "stackable": false,
++ "issuer": "service:inventory",
++ "workshop": true
}
]
}
ddiff --git a/server/http/api.js b/server/http/api.js
iindex fdeda62..6a7ef2a 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -103,6 +103,12 @@ function createApi(ctx) {
instance: await inv.consume(issuerOf(req), req.params.id, { revoke: true, reason: req.body && typeof req.body.reason === 'string' ? req.body.reason.slice(0, 200) : null }),
})));
r.post('/definitions', principal.requireCapability('inventory.definition.manage'), limits.budget('inventory.define'), wrap(async (req, res) => res.status(201).json({ definition: await inv.createDefinition(issuerOf(req), req.body) })));
++ // Staff decide on a Workshop item in review (ADR-054 §6): a person whose Network role is staff, nobody else.
++ r.post('/definitions/:id/review', limits.budget('inventory.define'), wrap(async (req, res) => {
++ const staff = req.principal.kind === 'user' && req.viewer && req.viewer.kind === 'user' && ['admin', 'global_mod'].includes(req.viewer.role);
++ if (!staff) return http.sendProblem(res, 403, 'inventory.staff_only', { detail: 'Only OpenVibe staff review Workshop items', ctx: req.ov });
++ return res.json({ definition: await inv.review(req.principal.requester, req.params.id, req.body || {}) });
++ }));
r.patch('/definitions/:id', principal.requireCapability('inventory.definition.manage'), limits.budget('inventory.define'), wrap(async (req, res) => res.json({ definition: await inv.updateDefinition(issuerOf(req), req.params.id, req.body) })));
return r;
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex 65fa3cb..fe7e4d3 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -26,6 +26,7 @@ const PAGE_TEXT = {
'/': ['OpenVibe.Inventory home', 'OpenVibe.Inventory: Your items, on every OpenVibe site.'],
'/updates': ['What shipped on OpenVibe.Inventory', 'This site\'s update log, from the network changelog feed.'],
'/items': ['Every item', 'Every published item on OpenVibe.Inventory, by kind, with its rarity.'],
++ '/workshop': ['The Workshop', 'Badges made by people in the community, reviewed by staff before anyone sees them, given free by their makers.'],
};
function dayOf(ts) {
@@@ -48,6 +49,7 @@ function homeJsonLd(config) {
const publicPages = () => [
{ path: '/', changefreq: 'weekly', priority: 1.0 },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
++ { path: '/workshop', changefreq: 'daily', priority: 0.6 },
];
const CATALOG_PAGE = { path: '/items', changefreq: 'daily', priority: 0.7 };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex defe691..cb411eb 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -99,6 +99,7 @@ ${raw(showcase.steps({
{ title: 'Keep them in one place', text: 'Everything you own is here, with where it came from and when.' },
{ title: 'Wear them everywhere', text: 'Equip one item per slot. Every site that shows that kind shows what you chose.' },
{ title: 'Never for sale', text: 'Nothing here is bought, sold, traded or turned into money. Rarity is shown as it is.' },
++ { title: 'Or make one', text: 'In the Workshop you make a badge, staff review it, and you give it to the people you choose.', href: '/workshop' },
],
}))}
${raw(showcase.cta({ title: me ? 'See what you have' : 'Already earned something?', text: me ? 'Your items and what you are wearing, on one page.' : 'Sign in with your OpenVibe account to see your inventory and choose what to wear.', actions: me ? [{ label: 'Your inventory', href: '/me' }] : [{ label: 'Sign in with OpenVibe', href: '/auth/login?next=%2Fme' }, { label: 'The API', href: '/api/v1/kinds' }] }))}`,
ddiff --git a/server/inventory/account-data.js b/server/inventory/account-data.js
iindex f5c1ad7..faec838 100644
---- a/server/inventory/account-data.js
++++ b/server/inventory/account-data.js
@@@ -7,6 +7,9 @@
* deletion their equipped set and instances are deleted (grant receipts go with the instances, ON DELETE CASCADE);
* their ledger rows stay without their subject or name, so supply counts and each item's history stay
* true; a creator's definitions stay, without the creator credit
++ * Workshop the badges a person made are exported (made.json); on deletion each passes to its kind's issuer, without
++ * the credit or the review note, and is retired (nobody gives it any more; the owned ones stay), and the
++ * creator's gift receipts lose their name
*
* Nothing exported is a secret.
*/
@@@ -17,12 +20,18 @@ const TABLES = [
{ table: 'inventory_equipped', subject: 'owner_subject', file: 'equipped.json', columns: ['kind', 'slot', 'instance_id', 'updated_at'], order: 'updated_at' },
{ table: 'inventory_instances', subject: 'owner_subject', file: 'items.json', columns: ['id', 'definition_id', 'origin', 'state', 'attributes', 'serial', 'acquired_at'], order: 'acquired_at' },
{ table: 'inventory_ledger', subject: 'owner_subject', file: 'history.json', columns: ['at', 'action', 'instance_id', 'definition_id', 'actor', 'reason'], order: 'id', erase: { anonymize: {} } },
-- { table: 'inventory_definitions', subject: 'credit_subject', file: null, erase: { anonymize: { credit_name: null } } },
++ { table: 'inventory_definitions', subject: 'credit_subject', file: 'made.json', columns: ['id', 'kind', 'name', 'description', 'status', 'supply_cap', 'supply_issued', 'created_at', 'published_at'], order: 'created_at', erase: { anonymize: { credit_name: null } } },
];
/** A ledger row the person acted in (an equip they made) keeps the row and loses their name. */
async function extraErase(t, subjects, counts) {
-- counts.add(counts.retained, 'tombstones', await t.exec("UPDATE inventory_ledger SET actor = 'user:deleted' WHERE actor = ANY($1::text[])", [subjects.map((x) => `user:${x}`)]));
++ const people = subjects.map((x) => `user:${x}`);
++ counts.add(counts.retained, 'tombstones', await t.exec("UPDATE inventory_ledger SET actor = 'user:deleted' WHERE actor = ANY($1::text[])", [people]));
++ // Their Workshop items: the kind's issuer takes them over, retired, without the review note (ADR-054 §6).
++ counts.add(counts.retained, 'workshop items', await t.exec(`UPDATE inventory_definitions d SET issuer = k.issuer, review_note = NULL, reviewed_by = NULL,
++ status = CASE WHEN d.status = 'retired' THEN d.status ELSE 'retired' END FROM inventory_kinds k WHERE k.id = d.kind AND d.issuer = ANY($1::text[])`, [people]));
++ counts.add(counts.retained, 'gift receipts', await t.exec("UPDATE inventory_grants SET issuer = 'user:deleted' WHERE issuer = ANY($1::text[])", [people]));
++ await t.exec("UPDATE inventory_definitions SET reviewed_by = NULL WHERE reviewed_by = ANY($1::text[])", [people]);
}
function create({ db, log = console } = {}) {
ddiff --git a/server/inventory/store.js b/server/inventory/store.js
iindex f3f9fa7..dc7a881 100644
---- a/server/inventory/store.js
++++ b/server/inventory/store.js
@@@ -18,6 +18,9 @@ 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}$/;
++// The Workshop (ADR-054 §6): what a person may submit and give.
++const WORKSHOP = Object.freeze({ inReview: 5, perDay: 20, giftsPerDay: 100, maxCap: 10000 });
++const PERSON_RE = /^user:usr_[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}$/;
@@@ -35,7 +38,7 @@ function actorOf(requester) {
}
function kindRow(r) {
-- return { id: r.id, name: r.name, description: r.description || undefined, slots: r.slots, surfaces: r.surfaces, attributes: r.attribute_schema, stackable: r.stackable, issuer: r.issuer };
++ return { id: r.id, name: r.name, description: r.description || undefined, slots: r.slots, surfaces: r.surfaces, attributes: r.attribute_schema, stackable: r.stackable, issuer: r.issuer, ...(r.workshop ? { workshop: true } : {}) };
}
function definitionRow(r, aliases = []) {
@@@ -88,13 +91,13 @@ function createInventory({ s, outbox, log = console }) {
async function listKinds() { return (await db.many('SELECT * FROM inventory_kinds ORDER BY id')).map(kindRow); }
async function upsertKind(k) {
const now = s.iso();
-- await db.exec(`INSERT INTO inventory_kinds (id, name, description, slots, surfaces, attribute_schema, stackable, issuer, created_at, updated_at)
-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
++ await db.exec(`INSERT INTO inventory_kinds (id, name, description, slots, surfaces, attribute_schema, stackable, issuer, created_at, updated_at, workshop)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9, $10)
ON CONFLICT (id) DO UPDATE SET name = excluded.name, description = excluded.description, slots = excluded.slots, surfaces = excluded.surfaces,
-- attribute_schema = excluded.attribute_schema, stackable = excluded.stackable, issuer = excluded.issuer, updated_at = excluded.updated_at
-- WHERE (inventory_kinds.name, inventory_kinds.description, inventory_kinds.slots, inventory_kinds.surfaces, inventory_kinds.attribute_schema, inventory_kinds.stackable, inventory_kinds.issuer)
-- IS DISTINCT FROM (excluded.name, excluded.description, excluded.slots, excluded.surfaces, excluded.attribute_schema, excluded.stackable, excluded.issuer)`,
-- [k.id, k.name, k.description || null, JSON.stringify(k.slots || []), JSON.stringify(k.surfaces || []), JSON.stringify(k.attribute_schema || { type: 'object' }), !!k.stackable, k.issuer, now]);
++ attribute_schema = excluded.attribute_schema, stackable = excluded.stackable, issuer = excluded.issuer, updated_at = excluded.updated_at, workshop = excluded.workshop
++ WHERE (inventory_kinds.name, inventory_kinds.description, inventory_kinds.slots, inventory_kinds.surfaces, inventory_kinds.attribute_schema, inventory_kinds.stackable, inventory_kinds.issuer, inventory_kinds.workshop)
++ IS DISTINCT FROM (excluded.name, excluded.description, excluded.slots, excluded.surfaces, excluded.attribute_schema, excluded.stackable, excluded.issuer, excluded.workshop)`,
++ [k.id, k.name, k.description || null, JSON.stringify(k.slots || []), JSON.stringify(k.surfaces || []), JSON.stringify(k.attribute_schema || { type: 'object' }), !!k.stackable, k.issuer, now, !!k.workshop]);
}
async function checkAttributes(kind, attributes) {
const row = await db.maybe('SELECT attribute_schema, updated_at FROM inventory_kinds WHERE id = $1', [kind]);
@@@ -149,20 +152,34 @@ function createInventory({ s, outbox, log = console }) {
}
/** A definition in the kind's namespace, by the kind's issuer (services and apps; creators open with the Workshop). */
-- async function createDefinition(requester, b, { status = null } = {}) {
++ async function createDefinition(requester, b, { status = null, creditName = null } = {}) {
if (!b || typeof b !== 'object') fail(400, 'request.bad_body', 'send inventory.definition-request@1');
const kind = await getKind(b.kind);
if (!kind) fail(422, 'inventory.unknown_kind', `no kind ${b.kind}`);
-- if (kind.issuer !== requester) fail(403, 'inventory.not_issuer', `only ${kind.issuer} defines ${kind.id} items (community items open with the Workshop, ADR-054 §6)`);
++ // The Workshop (ADR-054 §6): any person submits a definition of a Workshop kind, issued by and credited to them,
++ // in review until staff publish it: an image, common, with a cap they choose.
++ const workshop = !!kind.workshop && PERSON_RE.test(requester);
++ if (kind.issuer !== requester && !workshop) fail(403, 'inventory.not_issuer', `only ${kind.issuer} defines ${kind.id} items${kind.workshop ? '' : ' (community items open with the Workshop, ADR-054 §6)'}`);
++ if (workshop) {
++ 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`);
++ b = { ...b, rarity: 'common' };
++ }
checkDefinitionBody(b);
await checkAttributes(kind.id, b.attributes || {});
const id = s.newId('itd');
const now = s.iso();
-- const st = status || (b.publish ? 'published' : 'draft');
++ const st = workshop ? 'in_review' : (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, 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 || [])]);
++ 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, credit_subject, credit_name)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11, $12, $13, $14, $15)`,
++ [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 || []),
++ workshop ? requester.slice('user:'.length) : null, workshop && creditName ? String(creditName).slice(0, 64) : null]);
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}`);
@@@ -228,6 +245,12 @@ function createInventory({ s, outbox, log = console }) {
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}`);
++ // A creator gives their Workshop item (ADR-054 §6): granted, never earned, at most 100 gifts a day.
++ if (PERSON_RE.test(requester)) {
++ if (origin !== 'granted') fail(422, 'inventory.bad_origin', 'a creator gives an item: origin granted');
++ const today = Number(await db.value('SELECT count(*) FROM inventory_grants WHERE issuer = $1 AND left(created_at, 10) = $2::text', [requester, s.iso().slice(0, 10)]));
++ if (today >= WORKSHOP.giftsPerDay) fail(429, 'inventory.gifts_today', `at most ${WORKSHOP.giftsPerDay} gifts a day`);
++ }
const kind = await getKind(def.kind);
if (b.attributes !== undefined) await checkAttributes(kind.id, { ...def.attributes, ...b.attributes });
if (!kind.stackable) {
@@@ -299,13 +322,13 @@ function createInventory({ s, outbox, log = console }) {
async function equipped(subjects) {
const list = [...new Set((subjects || []).map(String))].filter((x) => SUBJECT_RE.test(x)).slice(0, 100);
if (!list.length) return [];
-- const rows = await db.many(`SELECT e.owner_subject, e.kind, e.slot, e.instance_id, e.updated_at, i.definition_id, d.art->>'token' AS token
++ const rows = await db.many(`SELECT e.owner_subject, e.kind, e.slot, e.instance_id, e.updated_at, i.definition_id, d.art->>'token' AS token, d.art->>'media_id' AS media_id
FROM inventory_equipped e JOIN inventory_instances i ON i.id = e.instance_id JOIN inventory_definitions d ON d.id = i.definition_id
WHERE e.owner_subject = ANY($1::text[]) AND i.state = 'owned' ORDER BY e.owner_subject, e.kind, e.slot`, [list]);
const by = new Map(list.map((x) => [x, { subject: x, slots: {}, updated_at: null }]));
for (const r of rows) {
const e = by.get(r.owner_subject);
-- e.slots[`${r.kind}:${r.slot}`] = { instance_id: r.instance_id, definition_id: r.definition_id, ...(r.token ? { token: r.token } : {}) };
++ e.slots[`${r.kind}:${r.slot}`] = { instance_id: r.instance_id, definition_id: r.definition_id, ...(r.token ? { token: r.token } : {}), ...(r.media_id ? { media_id: r.media_id } : {}) };
if (!e.updated_at || r.updated_at > e.updated_at) e.updated_at = r.updated_at;
}
const fallback = s.iso();
@@@ -341,10 +364,43 @@ function createInventory({ s, outbox, log = console }) {
return (await equipped([subject]))[0];
}
++ // ── The Workshop (ADR-054 §6) ─────────────────────────
++ /**
++ * Staff decide on a Workshop item in review: publish it (common unless they choose a rarity) or reject it with a
++ * reason. A rejected item goes back to draft with the note, which only its creator sees.
++ */
++ async function review(reviewer, id, b) {
++ if (!b || !['publish', 'reject'].includes(b.decision)) fail(422, 'inventory.bad_review', 'decision is publish or reject');
++ if (b.decision === 'reject' && !(typeof b.reason === 'string' && b.reason.trim() && b.reason.length <= 300)) fail(422, 'inventory.bad_review', 'a rejection says why, in at most 300 characters');
++ if (b.rarity !== undefined && !RARITY.includes(b.rarity)) fail(422, 'inventory.bad_rarity', `rarity is one of ${RARITY.join(', ')}`);
++ return await s.tx(async () => {
++ const cur = await db.maybe('SELECT * FROM inventory_definitions WHERE id = $1 FOR UPDATE', [String(id || '')]);
++ if (!cur) fail(404, 'inventory.definition_not_found', 'no such definition');
++ if (cur.status !== 'in_review') fail(409, 'inventory.not_in_review', `the item is ${cur.status}`);
++ const now = s.iso();
++ const publish = b.decision === 'publish';
++ await db.exec(`UPDATE inventory_definitions SET status = $2, rarity = $3, review_note = $4, reviewed_by = $5, reviewed_at = $6, updated_at = $6,
++ published_at = CASE WHEN $2 = 'published' THEN $6 ELSE published_at END WHERE id = $1`,
++ [cur.id, publish ? 'published' : 'draft', publish && b.rarity ? b.rarity : cur.rarity, publish ? null : b.reason.trim(), reviewer, now]);
++ await ledger(publish ? 'published' : 'rejected', { definition: cur.id, actor: reviewer, reason: publish ? null : b.reason.trim() });
++ if (publish) await emit('inventory.definition.published', { subject: { type: 'item_definition', id: cur.id }, visibility: 'public', actor: reviewer, payload: { definition_id: cur.id, kind: cur.kind, name: cur.name, rarity: b.rarity || cur.rarity, issuer: cur.issuer } });
++ return await getDefinition(cur.id);
++ });
++ }
++
++ /** 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
++ WHERE k.workshop AND ($1::text IS NULL OR d.status = $1) AND ($2::text IS NULL OR d.issuer = $2)
++ ORDER BY CASE WHEN d.status = 'in_review' THEN d.created_at END ASC, d.updated_at DESC LIMIT $3`,
++ [status, issuer, Math.max(1, Math.min(500, Number(limit) || 100))]);
++ return rows.map((r) => ({ ...definitionRow(r), ...(r.review_note ? { review_note: r.review_note } : {}) }));
++ }
++
return {
listKinds, getKind, upsertKind, listDefinitions, getDefinition, byAlias, createDefinition, updateDefinition,
-- grant, consume, inventory, equipped, equip, ledger,
++ grant, consume, inventory, equipped, equip, ledger, review, workshopItems,
};
}
--module.exports = { createInventory, InventoryError, actorOf, SUBJECT_RE, DEF_RE, INST_RE, RARITY };
++module.exports = { createInventory, InventoryError, actorOf, SUBJECT_RE, DEF_RE, INST_RE, RARITY, WORKSHOP };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex cb5f5ed..f571ec0 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -26,6 +26,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const NAV = [
{ label: 'Every item', href: '/items' },
{ label: 'Your inventory', href: '/me' },
++ { label: 'Workshop', href: '/workshop' },
{ label: 'What shipped', href: '/updates' },
];
ddiff --git a/server/workshop/image.js b/server/workshop/image.js
nnew file mode 100644
iindex 0000000..23fb581
---- /dev/null
++++ b/server/workshop/image.js
@@@ -0,0 +1,53 @@
++'use strict';
++
++/**
++ * A Workshop badge image (ADR-054 §6): PNG or WebP, square, 64 to 512 pixels on a side, at most 200 KB. The type and
++ * the size are read from the file's own header bytes, never from its name or the browser's word for it.
++ *
++ * inspect(buffer) → { type: 'image/png' | 'image/webp', width, height } | { error }
++ */
++const MAX_BYTES = 200 * 1024;
++const MIN_SIDE = 64;
++const MAX_SIDE = 512;
++
++function pngSize(b) {
++ // 8-byte signature, then the IHDR chunk: length (4), "IHDR" (4), width (4), height (4).
++ if (b.length < 24 || b.readUInt32BE(0) !== 0x89504e47 || b.readUInt32BE(4) !== 0x0d0a1a0a || b.toString('latin1', 12, 16) !== 'IHDR') return null;
++ return { width: b.readUInt32BE(16), height: b.readUInt32BE(20) };
++}
++
++function webpSize(b) {
++ if (b.length < 30 || b.toString('latin1', 0, 4) !== 'RIFF' || b.toString('latin1', 8, 12) !== 'WEBP') return null;
++ const chunk = b.toString('latin1', 12, 16);
++ if (chunk === 'VP8 ') {
++ // Lossy: a 3-byte frame tag, the start code 9d 01 2a, then 14-bit width and height.
++ if (b[23] !== 0x9d || b[24] !== 0x01 || b[25] !== 0x2a) return null;
++ return { width: b.readUInt16LE(26) & 0x3fff, height: b.readUInt16LE(28) & 0x3fff };
++ }
++ if (chunk === 'VP8L') {
++ // Lossless: signature 0x2f, then 14 bits of width-1 and 14 bits of height-1.
++ if (b[20] !== 0x2f) return null;
++ const bits = b.readUInt32LE(21);
++ return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 };
++ }
++ if (chunk === 'VP8X') {
++ // Extended: 24-bit canvas width-1 and height-1 after 4 bytes of flags.
++ return { width: b.readUIntLE(24, 3) + 1, height: b.readUIntLE(27, 3) + 1 };
++ }
++ return null;
++}
++
++function inspect(buffer) {
++ if (!Buffer.isBuffer(buffer) || !buffer.length) return { error: 'Choose an image file.' };
++ if (buffer.length > MAX_BYTES) return { error: `The image is ${Math.ceil(buffer.length / 1024)} KB; a badge is at most ${MAX_BYTES / 1024} KB.` };
++ let type = null;
++ let size = pngSize(buffer);
++ if (size) type = 'image/png';
++ else if ((size = webpSize(buffer))) type = 'image/webp';
++ if (!type) return { error: 'A badge is a PNG or WebP image.' };
++ if (size.width !== size.height) return { error: `A badge is square; this image is ${size.width}×${size.height}.` };
++ if (size.width < MIN_SIDE || size.width > MAX_SIDE) return { error: `A badge is ${MIN_SIDE} to ${MAX_SIDE} pixels on a side; this one is ${size.width}.` };
++ return { type, width: size.width, height: size.height };
++}
++
++module.exports = { inspect, MAX_BYTES, MIN_SIDE, MAX_SIDE };
ddiff --git a/server/workshop/media.js b/server/workshop/media.js
nnew file mode 100644
iindex 0000000..3443610
---- /dev/null
++++ b/server/workshop/media.js
@@@ -0,0 +1,49 @@
++'use strict';
++
++/**
++ * Workshop images in OpenVibe.Media: Inventory's own tenant (`inventory`, its app key in INVENTORY_MEDIA_APP_KEY),
++ * a public `asset` object per badge, served by Media at https://openvibe.media/o/<med_…>. The badge is shown to
++ * nobody until staff publish it (the pages never link an image of an item in review except to its creator and to
++ * staff); a rejected or retired item keeps its object so the review record stays whole.
++ *
++ * upload({ buffer, type, name, ownerSubject }) → med_… id (init → content → complete; throws with a short reason)
++ */
++function createWorkshopMedia({ config, fetchImpl = globalThis.fetch, log = console }) {
++ const base = () => `${String(config.media.url).replace(/\/+$/, '')}/api/v2/${encodeURIComponent(config.media.app)}/objects`;
++ const enabled = () => Boolean(config.media.appKey);
++
++ async function call(path, { method = 'POST', json, body, headers = {} } = {}) {
++ const h = { Accept: 'application/json', Authorization: `Bearer ${config.media.appKey}`, ...headers };
++ let payload = body;
++ if (json !== undefined) { payload = JSON.stringify(json); h['Content-Type'] = 'application/json'; }
++ let res;
++ try {
++ res = await fetchImpl(`${base()}${path}`, { method, headers: h, body: payload, signal: AbortSignal.timeout(config.media.timeoutMs) });
++ } catch (err) {
++ log.warn('[Workshop] OpenVibe.Media did not answer:', (err && err.name) || '');
++ throw new Error('OpenVibe.Media did not answer; try again in a minute.');
++ }
++ const data = await res.json().catch(() => null);
++ if (!res.ok) {
++ const p = (data && (data.problem || data)) || {};
++ throw new Error(`OpenVibe.Media refused the image (${p.code || res.status}).`);
++ }
++ return data || {};
++ }
++
++ async function upload({ buffer, type, name, ownerSubject }) {
++ if (!enabled()) throw new Error('Image uploads are not set up yet (INVENTORY_MEDIA_APP_KEY).');
++ // The same call shape as OpenVibe.MediaHub's client: the owner in X-OV-Subject, a printable-ASCII filename label.
++ const filename = String(name || 'badge').replace(/[^\x20-\x7e]/g, '').replace(/["\\]/g, '').trim().slice(0, 100) || 'badge';
++ const init = await call('', { headers: { 'X-OV-Subject': ownerSubject }, json: { kind: 'asset', visibility: 'public', size_bytes: buffer.length, mime_type: type, filename: `${filename}.${type === 'image/png' ? 'png' : 'webp'}` } });
++ const id = (init.object && init.object.id) || init.id;
++ if (!/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || ''))) throw new Error('OpenVibe.Media answered without an object id.');
++ await call(`/${id}/content`, { method: 'PUT', body: buffer, headers: { 'Content-Type': 'application/octet-stream', 'Content-Length': String(buffer.length) } });
++ await call(`/${id}/complete`, { json: {} });
++ return id;
++ }
++
++ return { upload, enabled };
++}
++
++module.exports = { createWorkshopMedia };
ddiff --git a/server/workshop/pages.js b/server/workshop/pages.js
nnew file mode 100644
iindex 0000000..4e20f31
---- /dev/null
++++ b/server/workshop/pages.js
@@@ -0,0 +1,221 @@
++'use strict';
++
++/**
++ * The Workshop (ADR-054 §6, plan T21): community-made items, free. Every page works without JavaScript.
++ *
++ * /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
++ *
++ * 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).
++ */
++const express = require('express');
++const Busboy = require('busboy');
++const { asyncRouter } = require('../http/router');
++const { sameOrigin } = require('../http/principal');
++const { html } = require('../render/html');
++const { send } = require('../render/layout');
++const { InventoryError, SUBJECT_RE, RARITY, WORKSHOP } = require('../inventory/store');
++const { inspect, MAX_BYTES, MIN_SIDE, MAX_SIDE } = require('./image');
++
++const KIND = 'network.badge';
++const STAFF_ROLES = new Set(['admin', 'global_mod']);
++const RARITY_LABEL = { common: 'Common', uncommon: 'Uncommon', rare: 'Rare', epic: 'Epic', legendary: 'Legendary' };
++const STATUS_LABEL = { in_review: 'In review', published: 'Published', draft: 'Not accepted', retired: 'Retired' };
++
++/** One multipart form with at most one file: → { fields, file: { buffer, truncated } | null }. */
++function parseForm(req, { maxBytes }) {
++ return new Promise((resolve, reject) => {
++ let bb;
++ try { bb = Busboy({ headers: req.headers, limits: { fileSize: maxBytes + 1, files: 1, fields: 10, fieldSize: 2000 } }); } catch (err) { return reject(err); }
++ const fields = {};
++ let file = null;
++ bb.on('field', (name, value) => { fields[name] = value; });
++ bb.on('file', (_name, stream) => {
++ const chunks = [];
++ let size = 0;
++ let truncated = false;
++ stream.on('data', (c) => { size += c.length; if (size <= maxBytes + 1) chunks.push(c); });
++ stream.on('limit', () => { truncated = true; });
++ stream.on('end', () => { file = { buffer: Buffer.concat(chunks), truncated: truncated || size > maxBytes }; });
++ });
++ bb.on('error', reject);
++ bb.on('close', () => resolve({ fields, file }));
++ req.pipe(bb);
++ });
++}
++
++const img = (d, size = 64) => html`<img class="ws-img" src="https://openvibe.media/o/${d.art.media_id}" alt="" width="${size}" height="${size}" loading="lazy">`;
++
++function createWorkshopRoutes(ctx) {
++ const { config, inv, workshopMedia, people } = ctx;
++ const r = asyncRouter();
++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
++ const signedIn = (req) => (req.viewer && req.viewer.kind === 'user' && SUBJECT_RE.test(String(req.viewer.subject || '')) ? req.viewer.subject : null);
++ const isStaff = (req) => !!(signedIn(req) && STAFF_ROLES.has(req.viewer.role));
++ const login = (res, path) => res.redirect(303, `/auth/login?next=${encodeURIComponent(path)}`);
++ const back = (res, path, key, value) => res.redirect(303, `${path}?${key}=${encodeURIComponent(String(value).slice(0, 200))}`);
++ const notice = (req) => html`${req.query.done ? html`<p class="notice ok" role="status">${String(req.query.done).slice(0, 200)}</p>` : ''}${req.query.error ? html`<p class="notice bad" role="alert">${String(req.query.error).slice(0, 200)}</p>` : ''}`;
++
++ // ── The Workshop ─────────────────────────────────────────
++ r.get('/workshop', async (req, res) => {
++ const published = await inv.workshopItems({ status: 'published', limit: 200 });
++ page(req, res, {
++ title: 'The Workshop',
++ description: 'Badges made by people in the OpenVibe community, reviewed by staff, given free by their makers.',
++ body: html`<h1>The Workshop</h1>
++<p class="lede">Badges made by people in the community. Make one, staff review it, then you give it to the people you choose: your viewers, your server, the friends you played with. Every badge here is free: nothing in the Workshop is sold or traded.</p>
++<p><a class="inv-btn primary" href="/workshop/new">Make a badge</a> <a class="inv-btn" href="/workshop/mine">Your badges</a></p>
++<h2>How it works</h2>
++<ol class="ws-steps">
++ <li><strong>Make it.</strong> A square PNG or WebP, ${MIN_SIDE}–${MAX_SIDE} pixels, at most ${MAX_BYTES / 1024} KB, that you made or may use, a name, and how many may ever exist (up to ${WORKSHOP.maxCap.toLocaleString('en-US')}).</li>
++ <li><strong>Staff review it.</strong> Safety, art rights and an honest rarity, before anyone else sees it. If it is not accepted you see why.</li>
++ <li><strong>Give it.</strong> Type someone's name and it lands in their inventory, up to ${WORKSHOP.giftsPerDay} a day. They wear it before their name in chat and on their profile.</li>
++</ol>
++<h2>Community badges <span class="muted small">${published.length}</span></h2>
++${published.length ? html`<ul class="inv-grid">${published.map((d) => html`<li class="inv-card r-${d.rarity}"><a class="inv-card-link" href="/items/${d.id}"><span class="inv-art inv-art-md r-${d.rarity}">${img(d)}</span><span class="inv-card-body"><span class="inv-card-name">${d.name}</span><span class="inv-rarity r-${d.rarity}">${RARITY_LABEL[d.rarity]}</span></span></a>${d.credit && d.credit.name ? html`<p class="inv-card-meta">by @${d.credit.name} · ${d.supply.issued} given</p>` : ''}</li>`)}</ul>` : html`<p class="muted">None yet. Yours could be the first.</p>`}`,
++ });
++ });
++
++ // ── Make a badge ─────────────────────────────────────────
++ r.get('/workshop/new', async (req, res) => {
++ if (!signedIn(req)) return login(res, '/workshop/new');
++ page(req, res, {
++ title: 'Make a badge',
++ robots: 'noindex',
++ body: html`<h1>Make a badge</h1>
++${notice(req)}
++<form class="ws-form" method="post" action="/workshop/new" enctype="multipart/form-data">
++ <label>Image <input type="file" name="image" accept="image/png,image/webp" required></label>
++ <p class="muted small">A square PNG or WebP, ${MIN_SIDE} to ${MAX_SIDE} pixels on a side, at most ${MAX_BYTES / 1024} KB. It shows small, before a name: keep it simple and readable.</p>
++ <label>Name <input type="text" name="name" maxlength="80" required placeholder="OG Viewer"></label>
++ <label>What it means (optional) <textarea name="description" maxlength="500" rows="2" placeholder="For everyone who was in chat for the first stream."></textarea></label>
++ <label>How many may ever exist <input type="number" name="supply_cap" min="1" max="${WORKSHOP.maxCap}" value="100" required></label>
++ <label class="ws-check"><input type="checkbox" name="rights" value="yes" required> <span>I made this image, or I have the right to use it, and it follows the <a href="/terms">rules</a>.</span></label>
++ <button type="submit" class="inv-btn primary">Send for review</button>
++</form>
++<p class="muted small">Staff review every badge before anyone else sees it. At most ${WORKSHOP.inReview} in review at once and ${WORKSHOP.perDay} a day.</p>`,
++ });
++ });
++
++ r.post('/workshop/new', async (req, res) => {
++ const me = signedIn(req);
++ if (!me) return login(res, '/workshop/new');
++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('That form must come from this site.');
++ let form;
++ try { form = await parseForm(req, { maxBytes: MAX_BYTES }); } catch { return back(res, '/workshop/new', 'error', 'That upload could not be read; try again.'); }
++ const f = form.fields;
++ if (f.rights !== 'yes') return back(res, '/workshop/new', 'error', 'Confirm that you made the image or may use it.');
++ if (!form.file || form.file.truncated) return back(res, '/workshop/new', 'error', form.file ? `A badge is at most ${MAX_BYTES / 1024} KB.` : 'Choose an image file.');
++ const pic = inspect(form.file.buffer);
++ if (pic.error) return back(res, '/workshop/new', 'error', pic.error);
++ const cap = Number.parseInt(f.supply_cap, 10);
++ try {
++ 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,
++ art: { media_id: mediaId }, rarity: 'common', attributes: {}, supply_cap: cap,
++ }, { creditName: req.viewer.username });
++ return back(res, '/workshop/mine', 'done', 'Sent for review. You will see the decision here.');
++ } catch (err) {
++ if (err instanceof InventoryError) return back(res, '/workshop/new', 'error', err.detail || err.code);
++ if (err && /OpenVibe\.Media|uploads are not set up/.test(String(err.message))) return back(res, '/workshop/new', 'error', err.message);
++ throw err;
++ }
++ });
++
++ // ── Your badges ──────────────────────────────────────────
++ r.get('/workshop/mine', async (req, res) => {
++ const me = signedIn(req);
++ if (!me) return login(res, '/workshop/mine');
++ const mine = await inv.workshopItems({ issuer: `user:${me}`, limit: 200 });
++ page(req, res, {
++ title: 'Your badges',
++ robots: 'noindex',
++ body: html`<h1>Your badges</h1>
++${notice(req)}
++<p><a class="inv-btn primary" href="/workshop/new">Make a badge</a> <a class="inv-btn" href="/workshop">The Workshop</a></p>
++${mine.length ? html`<ul class="ws-mine">${mine.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} <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 === '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">
++ <label>Give it to <input type="text" name="to" required maxlength="25" placeholder="@username" pattern="@?[A-Za-z0-9_]{3,24}"></label>
++ <button type="submit" class="inv-btn primary">Give</button>
++ </form>` : html`<p class="muted">All ${d.supply.cap} have been given.</p>`}` : ''}
++ </div>
++</li>`)}</ul>` : html`<p class="muted">You have not made a badge yet.</p>`}`,
++ });
++ });
++
++ r.post('/workshop/:id/give', 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 to = String((req.body || {}).to || '').trim().replace(/^@/, '');
++ let subject;
++ try { subject = await people.subjectOf(to); } catch { return back(res, '/workshop/mine', 'error', 'OpenVibe.Network did not answer; try again in a minute.'); }
++ if (!subject) return back(res, '/workshop/mine', 'error', `Nobody is called @${to.slice(0, 24)}.`);
++ try {
++ const g = await inv.grant(`user:${me}`, { definition_id: String(req.params.id || ''), subject, idempotency_key: `ws:${req.params.id}:${subject}`, origin: 'granted', reason: 'Given in the Workshop' });
++ return back(res, '/workshop/mine', 'done', g.created ? `Given to @${to}. It is in their inventory now.` : `@${to} already has it.`);
++ } 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');
++ 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 });
++ page(req, res, {
++ title: 'Workshop review',
++ robots: 'noindex',
++ body: html`<h1>Workshop review <span class="muted small">${queue.length} waiting</span></h1>
++${notice(req)}
++<p class="muted">Check each badge for safety (nothing hateful, sexual, violent or personal), art rights (not someone else's artwork or a brand's logo) and an honest rarity. A rejection's reason is what its creator reads.</p>
++${queue.length ? html`<ul class="ws-mine">${queue.map((d) => html`<li class="ws-item">
++ <span class="inv-art inv-art-lg r-common">${img(d, 96)}</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} · cap ${d.supply.cap} · sent ${String(d.created_at).slice(0, 16).replace('T', ' ')} UTC</p>
++ ${d.description ? html`<p>${d.description}</p>` : ''}
++ <form method="post" action="/workshop/${d.id}/review" class="ws-review">
++ <label>Rarity <select name="rarity">${RARITY.map((x) => html`<option value="${x}"${x === 'common' ? ' selected' : ''}>${RARITY_LABEL[x]}</option>`)}</select></label>
++ <button type="submit" name="decision" value="publish" class="inv-btn primary">Publish</button>
++ <label class="ws-reason">Reason, if rejecting <input type="text" name="reason" maxlength="300" placeholder="The image is a brand's logo."></label>
++ <button type="submit" name="decision" value="reject" class="inv-btn">Reject</button>
++ </form>
++ </div>
++</li>`)}</ul>` : html`<p class="muted">Nothing waiting.</p>`}`,
++ });
++ });
++
++ 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.');
++ 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 d = await inv.review(`user:${req.viewer.subject}`, String(req.params.id || ''), {
++ decision: String(b.decision || ''), reason: b.reason ? String(b.reason) : undefined,
++ rarity: b.decision === 'publish' && b.rarity ? String(b.rarity) : undefined,
++ });
++ return back(res, '/workshop/review', 'done', `${d.name}: ${d.status === 'published' ? 'published' : 'not accepted'}.`);
++ } catch (err) {
++ if (err instanceof InventoryError) return back(res, '/workshop/review', 'error', err.detail || err.code);
++ throw err;
++ }
++ });
++
++ return r;
++}
++
++module.exports = { createWorkshopRoutes, parseForm, STAFF_ROLES };
ddiff --git a/server/workshop/people.js b/server/workshop/people.js
nnew file mode 100644
iindex 0000000..c5b194a
---- /dev/null
++++ b/server/workshop/people.js
@@@ -0,0 +1,43 @@
++'use strict';
++
++/**
++ * Who a creator means by "@name" when they give a Workshop item: OpenVibe.Network's
++ * GET /internal/identity/resolve?username=… (a person's current name, any case), read with this service's own client
++ * credentials for audience openvibe.network and identity.subject.resolve. Only the subject is used.
++ *
++ * subjectOf(name) → usr_… | null (null: nobody by that name; throws when the Network cannot answer)
++ */
++const { serviceAuth } = require('openvibe-contracts');
++
++const NAME_RE = /^[A-Za-z0-9_]{3,24}$/;
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++function createPeople({ config, fetchImpl = globalThis.fetch }) {
++ const base = String(config.networkInternalUrl || '').replace(/\/+$/, '');
++ const tokens = serviceAuth.createTokenClient({
++ tokenUrl: `${base}/oauth/token`,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.network',
++ scope: 'identity.subject.resolve',
++ fetchImpl,
++ });
++
++ async function subjectOf(name) {
++ const n = String(name || '').trim().replace(/^@/, '');
++ if (!NAME_RE.test(n)) return null;
++ const res = await fetchImpl(`${base}/internal/identity/resolve?username=${encodeURIComponent(n)}`, {
++ headers: { Accept: 'application/json', ...(await tokens.authHeaders()) },
++ signal: AbortSignal.timeout(5000),
++ });
++ if (res.status === 404) return null;
++ if (!res.ok) throw new Error(`OpenVibe.Network answered ${res.status}`);
++ const body = await res.json().catch(() => ({}));
++ const subject = body.subject_id || (body.projection && body.projection.subject_id) || null;
++ return SUBJECT_RE.test(String(subject || '')) ? subject : null;
++ }
++
++ return { subjectOf };
++}
++
++module.exports = { createPeople };
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex d0f63a1..8f834c0 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -81,6 +81,25 @@ async function startNetworkStub() {
assert.strictEqual(stub.calls.filter((c) => c.url.includes('/confirmations')).length, 1);
});
++ await check('a Workshop creator: what they made is exported; on deletion it passes to the kind, retired, without them', async () => {
++ const erin = t.network.addUser('erin');
++ const inv = t.ctx.inv;
++ const made = await inv.createDefinition(`user:${erin.subject}`, { kind: 'network.badge', name: 'Erin Badge', art: { media_id: 'med_01JZ0000000000000000000077' }, rarity: 'common', attributes: {}, supply_cap: 5 }, { creditName: 'erin' });
++ await inv.review('user:usr_01JZ00000000000000000000ST', made.id, { decision: 'publish' });
++ await inv.grant(`user:${erin.subject}`, { definition_id: made.id, subject: bob.subject, idempotency_key: `ws:${made.id}:${bob.subject}`, origin: 'granted' });
++ const exp = await deliver(ev('evt_01JZ0000000000000000000E02', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX2', subject: erin.subject }));
++ assert.strictEqual(exp.status, 200);
++ const part = stub.calls.find((c) => c.url === '/internal/account-exports/exp_01JZ0000000000000000000EX2/parts');
++ const file = part.body.files.find((f) => f.name === 'made.json');
++ assert.ok(file && file.content.length === 1 && file.content[0].name === 'Erin Badge', 'what she made is in her export');
++ const del = await deliver(ev('evt_01JZ0000000000000000000D02', 'network.account.deleted', { deletion_id: 'del_01JZ0000000000000000000DE2', subject: erin.subject }));
++ assert.strictEqual(del.json.outcome, 'erased', JSON.stringify(del.json));
++ const after = await t.ctx.s.db.maybe('SELECT issuer, status, credit_subject, credit_name FROM inventory_definitions WHERE id = $1', [made.id]);
++ assert.deepStrictEqual(after, { issuer: 'service:inventory', status: 'retired', credit_subject: null, credit_name: null });
++ assert.strictEqual(await count('SELECT count(*) FROM inventory_grants WHERE issuer = $1', [`user:${erin.subject}`]), 0, 'her gift receipts lose her name');
++ assert.strictEqual(await count('SELECT count(*) FROM inventory_instances WHERE definition_id = $1 AND owner_subject = $2', [made.id, bob.subject]), 1, 'bob keeps the badge she gave him');
++ });
++
await check('the route refuses a bad signature and a request that came through a proxy', async () => {
const event = ev('evt_01JZ0000000000000000000E02', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX2', subject: alice.subject });
assert.strictEqual((await deliver(event, { secret: `whsec_${'mismatch'.repeat(5)}` })).status, 401);
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 59055dd..6c94147 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -38,7 +38,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
-- const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend });
++ const built = await createApp({ config, store, log, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true, accountSend: opts.accountSend, workshopMedia: opts.workshopMedia, people: opts.people });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
ddiff --git a/test/inventory-api.test.js b/test/inventory-api.test.js
iindex a38c590..2741fbd 100644
---- a/test/inventory-api.test.js
++++ b/test/inventory-api.test.js
@@@ -24,9 +24,9 @@ const { boot, check, done } = require('./helpers/boot');
let granted;
try {
-- await check('boot registers Live\'s five kinds and 70 cosmetics, published, found by Live\'s ids', async () => {
++ await check('boot registers Live\'s five kinds, the Workshop\'s badge and Live\'s 70 cosmetics, published, found by Live\'s ids', async () => {
const kinds = (await api('/kinds')).json().kinds;
-- assert.deepStrictEqual(kinds.map((k) => k.id), ['live.chat_tag', 'live.hat', 'live.name_effect', 'live.particle', 'live.voice']);
++ assert.deepStrictEqual(kinds.map((k) => k.id), ['live.chat_tag', 'live.hat', 'live.name_effect', 'live.particle', 'live.voice', 'network.badge'], 'Live\'s five and the Workshop\'s badge');
for (const k of kinds) valid('inventory.kind@1', k);
const defs = (await api('/definitions?limit=500')).json().definitions;
assert.strictEqual(defs.length, 70);
@@@ -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, grantors: 0 });
++ assert.deepStrictEqual(await seed(t.ctx.inv, { log: { log() {} } }), { kinds: 6, definitions: 0, grantors: 0 });
});
await check('an issuer grants within its namespace, idempotently; a non-stackable item already owned answers the one held', async () => {
ddiff --git a/test/workshop.test.js b/test/workshop.test.js
nnew file mode 100644
iindex 0000000..57d4dd1
---- /dev/null
++++ b/test/workshop.test.js
@@@ -0,0 +1,147 @@
++'use strict';
++/**
++ * The Workshop (ADR-054 §6 amendment, Contracts 0.124.0). A person submits a badge through the no-JavaScript form (the
++ * image checked by its own bytes, stored through a stand-in Media); it is in review, credited to them and shown to
++ * nobody else; staff reject it with a reason the creator reads, or publish it with a rarity; the creator gives it by
++ * @name (granted, idempotent per person, within the cap); the person wears it and the equipped read carries its image.
++ * Non-staff never review; the limits hold; the kind is a Workshop kind in the public API.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { inspect } = require('../server/workshop/image');
++
++/** A PNG header for a w×h image (inspect reads only the signature and IHDR). */
++function png(w, h, pad = 200) {
++ const b = Buffer.alloc(33 + pad);
++ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(b, 0);
++ b.writeUInt32BE(13, 8); b.write('IHDR', 12, 'latin1'); b.writeUInt32BE(w, 16); b.writeUInt32BE(h, 20);
++ return b;
++}
++/** A lossless WebP header for a w×h image. */
++function webp(w, h) {
++ const b = Buffer.alloc(40);
++ b.write('RIFF', 0, 'latin1'); b.writeUInt32LE(32, 4); b.write('WEBP', 8, 'latin1'); b.write('VP8L', 12, 'latin1');
++ b.writeUInt32LE(20, 16); b[20] = 0x2f; b.writeUInt32LE(((w - 1) & 0x3fff) | (((h - 1) & 0x3fff) << 14), 21);
++ return b;
++}
++function multipart(fields, file) {
++ const boundary = '----ovtest' + Math.random().toString(16).slice(2);
++ const parts = [];
++ for (const [k, v] of Object.entries(fields)) parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));
++ if (file) parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="image"; filename="b.png"\r\nContent-Type: image/png\r\n\r\n`), file, Buffer.from('\r\n'));
++ parts.push(Buffer.from(`--${boundary}--\r\n`));
++ return { body: Buffer.concat(parts), type: `multipart/form-data; boundary=${boundary}` };
++}
++
++(async () => {
++ const uploads = [];
++ let n = 0;
++ const workshopMedia = { enabled: () => true, async upload(o) { uploads.push(o); n++; return `med_01JZ00000000000000000000${String(n).padStart(2, '0')}`; } };
++ const names = new Map();
++ const people = { async subjectOf(name) { return names.get(String(name).replace(/^@/, '').toLowerCase()) || null; } };
++ const t = await boot({ workshopMedia, people });
++ const alice = t.network.addUser('alice');
++ const carol = t.network.addUser('carol');
++ const staff = t.network.addUser('boss', { role: 'admin' });
++ names.set('carol', carol.subject);
++ const origin = { origin: t.base };
++ const submit = async (who, fields, file) => {
++ const m = multipart(fields, file);
++ return t.get('/workshop/new', { as: who, method: 'POST', body: m.body, headers: { 'content-type': m.type, ...origin } });
++ };
++ const form = (who, path, fields) => t.get(path, { as: who, method: 'POST', form: fields, headers: origin });
++ const inv = t.ctx.inv;
++ try {
++ await check('images are judged by their own bytes: square PNG or WebP, 64 to 512 pixels, at most 200 KB', async () => {
++ assert.deepStrictEqual(inspect(png(128, 128)), { type: 'image/png', width: 128, height: 128 });
++ assert.deepStrictEqual(inspect(webp(64, 64)), { type: 'image/webp', width: 64, height: 64 });
++ assert.match(inspect(png(128, 96)).error, /square/);
++ assert.match(inspect(png(32, 32)).error, /64 to 512/);
++ assert.match(inspect(png(128, 128, 210 * 1024)).error, /at most 200 KB/);
++ assert.match(inspect(Buffer.from('GIF89a........................')).error, /PNG or WebP/);
++ });
++
++ await check('network.badge is a Workshop kind in the public API', async () => {
++ const kinds = (await t.get('/api/v1/kinds')).json().kinds;
++ const badge = kinds.find((k) => k.id === 'network.badge');
++ assert.deepStrictEqual([badge.workshop, badge.slots, badge.surfaces.map((s) => s.renderer)], [true, ['badge'], ['network.badge.image@1', 'network.badge.image@1']]);
++ });
++
++ let first;
++ await check('a person submits a badge: in review, credited to them, shown to nobody else', async () => {
++ const r = await submit(alice, { name: 'OG Viewer', description: 'First stream crew', supply_cap: '50', rights: 'yes' }, png(128, 128));
++ assert.strictEqual(r.status, 303, r.text);
++ assert.match(r.headers.get('location'), /^\/workshop\/mine\?done=/);
++ assert.strictEqual(uploads[0].ownerSubject, alice.subject);
++ first = (await inv.workshopItems({ issuer: `user:${alice.subject}` }))[0];
++ assert.deepStrictEqual([first.status, first.rarity, first.issuer, first.credit, first.supply.cap, first.art], ['in_review', 'common', `user:${alice.subject}`, { subject: alice.subject, name: 'alice' }, 50, { media_id: 'med_01JZ0000000000000000000001' }]);
++ assert.ok(!(await t.get('/workshop')).text.includes('OG Viewer'), 'not on the public Workshop');
++ assert.strictEqual((await t.get(`/items/${first.id}`)).status, 404, 'no public item page');
++ assert.ok((await t.get('/workshop/mine', { as: alice })).text.includes('In review'));
++ });
++
++ await check('a bad submission is refused with its reason, and nothing is stored', async () => {
++ const before = uploads.length;
++ let r = await submit(alice, { name: 'Wide', supply_cap: '5', rights: 'yes' }, png(200, 100));
++ assert.match(decodeURIComponent(r.headers.get('location')), /square/);
++ r = await submit(alice, { name: 'No rights', supply_cap: '5' }, png(128, 128));
++ assert.match(decodeURIComponent(r.headers.get('location')), /made the image/);
++ const foreign = multipart({ name: 'x', supply_cap: '5', rights: 'yes' }, png(128, 128));
++ r = await t.get('/workshop/new', { as: alice, method: 'POST', body: foreign.body, headers: { 'content-type': foreign.type, origin: 'https://evil.example' } });
++ assert.strictEqual(r.status, 403, 'a form from another site');
++ assert.strictEqual(uploads.length, before);
++ });
++
++ await check('only staff see the queue and decide; a rejection reaches its creator', async () => {
++ assert.strictEqual((await t.get('/workshop/review', { as: alice })).status, 403);
++ assert.strictEqual((await form(alice, `/workshop/${first.id}/review`, { decision: 'publish' })).status, 403);
++ const q = await t.get('/workshop/review', { as: staff });
++ assert.ok(q.text.includes('OG Viewer') && q.text.includes('@alice'));
++ const r = await form(staff, `/workshop/${first.id}/review`, { decision: 'reject', reason: 'Too close to a channel logo.' });
++ assert.strictEqual(r.status, 303);
++ const after = await inv.getDefinition(first.id);
++ assert.strictEqual(after.status, 'draft');
++ assert.match((await t.get('/workshop/mine', { as: alice })).text, /Staff(&#39;|&#x27;|')s reason: Too close to a channel logo\./);
++ });
++
++ 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));
++ badge = (await inv.workshopItems({ status: 'in_review' })).find((d) => d.name === 'Night Owl');
++ const api = await t.get(`/api/v1/definitions/${badge.id}/review`, { as: staff, method: 'POST', json: { decision: 'publish', rarity: 'uncommon' }, headers: origin });
++ assert.strictEqual(api.status, 200, api.text);
++ assert.deepStrictEqual([api.json().definition.status, api.json().definition.rarity], ['published', 'uncommon']);
++ assert.ok((await t.get('/workshop')).text.includes('Night Owl'));
++ let r = await form(alice, `/workshop/${badge.id}/give`, { to: '@carol' });
++ assert.match(decodeURIComponent(r.headers.get('location')), /Given to @carol/);
++ r = await form(alice, `/workshop/${badge.id}/give`, { to: 'carol' });
++ assert.match(decodeURIComponent(r.headers.get('location')), /already has it/);
++ r = await form(alice, `/workshop/${badge.id}/give`, { to: 'nobody_here' });
++ assert.match(decodeURIComponent(r.headers.get('location')), /Nobody is called @nobody_here/);
++ const owned = (await inv.inventory(carol.subject, { own: true })).instances.find((i) => i.definition_id === badge.id);
++ assert.strictEqual(owned.origin, 'granted');
++ await inv.equip(carol.subject, `user:${carol.subject}`, { kind: 'network.badge', slot: 'badge', instance_id: owned.id });
++ const worn = (await t.get(`/api/v1/equipped?subjects=${carol.subject}`)).json().equipped[0].slots['network.badge:badge'];
++ assert.strictEqual(worn.media_id, badge.art.media_id, 'the equipped read carries the image');
++ // Someone who is not the creator cannot give it.
++ const notMine = await inv.grant(`user:${carol.subject}`, { definition_id: badge.id, subject: alice.subject, idempotency_key: 'ws:x:y-123', origin: 'granted' }).catch((e) => e);
++ assert.strictEqual(notMine.code, 'inventory.not_issuer');
++ });
++
++ await check('the limits: 5 in review at once; the API refuses non-staff; a creator gives, never earns', async () => {
++ const dave = t.network.addUser('dave');
++ 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 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);
++ assert.strictEqual(earned.code, 'inventory.bad_origin');
++ 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');
++ });
++ } finally {
++ await t.close();
++ }
++ done();
++})();