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)
Changes
@@@ -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=@@@ -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@@@ -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 withoutthem, 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 -->@@@ -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"],nnew file mode 100644@@@ -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';@@@ -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",@@@ -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"@@@ -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; }@@@ -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>` }));@@@ -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)),++ },};}@@@ -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}]}@@@ -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;@@@ -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 };@@@ -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' }] }))}`,@@@ -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 } = {}) {@@@ -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_idFROM inventory_equipped e JOIN inventory_instances i ON i.id = e.instance_id JOIN inventory_definitions d ON d.id = i.definition_idWHERE 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 };@@@ -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' },];nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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);@@@ -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}`;@@@ -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 () => {nnew file mode 100644@@@ -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('|'|')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();++})();
@@ -34,3 +34,8 @@ OV_OAUTH_CLIENT_SECRET=
@@ -34,3 +34,8 @@ OV_OAUTH_CLIENT_SECRET=
#EVENTS_URL=http://127.0.0.1:4300
#EVENTS_URL=http://127.0.0.1:4300
#EVENTS_RELAY_INTERVAL_MS=2000
#EVENTS_RELAY_INTERVAL_MS=2000
#INVENTORY_EVENTS_SECRET=
#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=
@@ -2,6 +2,18 @@
@@ -2,6 +2,18 @@
What changed in OpenVibe.Inventory, newest first. Each site also publishes its patch notes at /updates.
What changed in OpenVibe.Inventory, newest first. Each site also publishes its patch notes at /updates.
+## 0.4.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
## 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
- **Grantors** (ADR-054 §3 amendment, openvibe-contracts 0.123.0): a definition may name the services or apps that may
@@ -60,6 +60,18 @@ token that is presented must hold `inventory.item.read`.
@@ -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
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.
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
## Pages
`/` (what it is), `/items` (every item by kind), `/items/:id` (one item: where it shows, who gives it, how many exist),
`/` (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).
@@ -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).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.123.0
+- openvibe-contracts: v0.124.0
- openvibe-sdk: v0.37.0
- openvibe-sdk: v0.37.0
- openvibe-shared: v2.17.0
- openvibe-shared: v2.17.0
<!-- versions:end -->
<!-- versions:end -->
@@ -6,11 +6,11 @@
@@ -6,11 +6,11 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": "5030",
"port": "5030",
"domain": "inventory.openvibe.network",
"domain": "inventory.openvibe.network",
- "contracts": "openvibe-contracts v0.123.0",
+ "contracts": "openvibe-contracts v0.124.0",
"sdk": "openvibe-sdk v0.37.0",
"sdk": "openvibe-sdk v0.37.0",
"shared": "openvibe-shared v2.17.0",
"shared": "openvibe-shared v2.17.0",
"packages": [
"packages": [
- "openvibe-contracts v0.123.0",
+ "openvibe-contracts v0.124.0",
"openvibe-sdk v0.37.0",
"openvibe-sdk v0.37.0",
"openvibe-shared v2.17.0"
"openvibe-shared v2.17.0"
],
],
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -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';
@@ -1,22 +1,23 @@
@@ -1,22 +1,23 @@
{
{
"name": "openvibe-inventory",
"name": "openvibe-inventory",
- "version": "0.3.0",
+ "version": "0.4.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-inventory",
"name": "openvibe-inventory",
- "version": "0.3.0",
+ "version": "0.4.0",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.20.0",
"ajv": "^8.20.0",
+ "busboy": "^1.6.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.17.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -123,6 +124,17 @@
@@ -123,6 +124,17 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
"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": {
"node_modules/bytes": {
"version": "3.1.2",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -872,9 +884,9 @@
@@ -872,9 +884,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1343,6 +1355,14 @@
@@ -1343,6 +1355,14 @@
"node": ">= 0.8"
"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": {
"node_modules/toidentifier": {
"version": "1.0.1",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-inventory",
"name": "openvibe-inventory",
- "version": "0.3.0",
+ "version": "0.4.0",
"private": true,
"private": true,
"description": "OpenVibe.Inventory — Your items, on every OpenVibe site.",
"description": "OpenVibe.Inventory — Your items, on every OpenVibe site.",
"main": "server/index.js",
"main": "server/index.js",
@@ -17,13 +17,14 @@
@@ -17,13 +17,14 @@
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.20.0",
"ajv": "^8.20.0",
+ "busboy": "^1.6.0",
"cookie-parser": "^1.4.7",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.17.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -101,3 +101,24 @@ button.sc-btn.sc-primary { border: 0; }
@@ -101,3 +101,24 @@ button.sc-btn.sc-primary { border: 0; }
.inv-btn.primary:hover { filter: brightness(1.08); }
.inv-btn.primary:hover { filter: brightness(1.08); }
.inv-equip .inv-worn { margin-right: auto; }
.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); }
.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; }
@@ -28,6 +28,9 @@ const { createSso } = require('./auth/sso');
@@ -28,6 +28,9 @@ const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createPrincipal } = require('./http/principal');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createPageRoutes } = require('./http/pages');
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 { createServiceReadiness } = require('./observability');
const { createCallerLimits } = require('./http/caller-limits');
const { createCallerLimits } = require('./http/caller-limits');
const { createServiceOutbox } = require('openvibe-sdk/events');
const { createServiceOutbox } = require('openvibe-sdk/events');
@@ -70,6 +73,9 @@ async function createApp(opts = {}) {
@@ -70,6 +73,9 @@ async function createApp(opts = {}) {
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
? 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'); });
: 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 };
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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -159,6 +165,7 @@ async function createApp(opts = {}) {
@@ -159,6 +165,7 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.INVENTORY_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
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(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>` }));
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>` }));
@@ -63,6 +63,14 @@ function load(env = process.env) {
@@ -63,6 +63,14 @@ function load(env = process.env) {
intervalMs: Math.max(50, int(env.EVENTS_RELAY_INTERVAL_MS, 2000)),
intervalMs: Math.max(50, int(env.EVENTS_RELAY_INTERVAL_MS, 2000)),
secrets: String(env.INVENTORY_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
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)),
+ },
};
};
}
}
@@ -206,6 +206,32 @@
@@ -206,6 +206,32 @@
},
},
"stackable": false,
"stackable": false,
"issuer": "service:live"
"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
}
}
]
]
}
}
@@ -103,6 +103,12 @@ function createApi(ctx) {
@@ -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 }),
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) })));
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) })));
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;
return r;
@@ -26,6 +26,7 @@ const PAGE_TEXT = {
@@ -26,6 +26,7 @@ const PAGE_TEXT = {
'/': ['OpenVibe.Inventory home', 'OpenVibe.Inventory: Your items, on every OpenVibe site.'],
'/': ['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.'],
'/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.'],
'/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) {
function dayOf(ts) {
@@ -48,6 +49,7 @@ function homeJsonLd(config) {
@@ -48,6 +49,7 @@ function homeJsonLd(config) {
const publicPages = () => [
const publicPages = () => [
{ path: '/', changefreq: 'weekly', priority: 1.0 },
{ path: '/', changefreq: 'weekly', priority: 1.0 },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
{ path: '/updates', changefreq: 'daily', priority: 0.5 },
+ { path: '/workshop', changefreq: 'daily', priority: 0.6 },
];
];
const CATALOG_PAGE = { path: '/items', changefreq: 'daily', priority: 0.7 };
const CATALOG_PAGE = { path: '/items', changefreq: 'daily', priority: 0.7 };
@@ -99,6 +99,7 @@ ${raw(showcase.steps({
@@ -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: '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: '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: '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' }] }))}`,
${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' }] }))}`,
@@ -7,6 +7,9 @@
@@ -7,6 +7,9 @@
* deletion their equipped set and instances are deleted (grant receipts go with the instances, ON DELETE CASCADE);
* 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
* 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
* 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.
* Nothing exported is a secret.
*/
*/
@@ -17,12 +20,18 @@ const TABLES = [
@@ -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_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_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_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. */
/** A ledger row the person acted in (an equip they made) keeps the row and loses their name. */
async function extraErase(t, subjects, counts) {
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 } = {}) {
function create({ db, log = console } = {}) {
@@ -18,6 +18,9 @@ const Ajv = require('ajv/dist/2020');
@@ -18,6 +18,9 @@ const Ajv = require('ajv/dist/2020');
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const DEF_RE = /^itd_[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.
// 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 GRANTOR_RE = /^(service:[a-z][a-z0-9-]*|app:app_[0-9A-HJKMNP-TV-Z]{26})$/;
const INST_RE = /^inv_[0-9A-HJKMNP-TV-Z]{26}$/;
const INST_RE = /^inv_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -35,7 +38,7 @@ function actorOf(requester) {
@@ -35,7 +38,7 @@ function actorOf(requester) {
}
}
function kindRow(r) {
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 = []) {
function definitionRow(r, aliases = []) {
@@ -88,13 +91,13 @@ function createInventory({ s, outbox, log = console }) {
@@ -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 listKinds() { return (await db.many('SELECT * FROM inventory_kinds ORDER BY id')).map(kindRow); }
async function upsertKind(k) {
async function upsertKind(k) {
const now = s.iso();
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,
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) {
async function checkAttributes(kind, attributes) {
const row = await db.maybe('SELECT attribute_schema, updated_at FROM inventory_kinds WHERE id = $1', [kind]);
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 }) {
@@ -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). */
/** 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');
if (!b || typeof b !== 'object') fail(400, 'request.bad_body', 'send inventory.definition-request@1');
const kind = await getKind(b.kind);
const kind = await getKind(b.kind);
if (!kind) fail(422, 'inventory.unknown_kind', `no kind ${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);
checkDefinitionBody(b);
await checkAttributes(kind.id, b.attributes || {});
await checkAttributes(kind.id, b.attributes || {});
const id = s.newId('itd');
const id = s.newId('itd');
const now = s.iso();
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 () => {
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 || []) {
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]);
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}`);
if (taken) fail(409, 'inventory.alias_taken', `${alias} already names ${taken.definition_id}`);
@@ -228,6 +245,12 @@ function createInventory({ s, outbox, log = console }) {
@@ -228,6 +245,12 @@ function createInventory({ s, outbox, log = console }) {
const isGrantor = Array.isArray(def.grantors) && def.grantors.includes(requester);
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.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}`);
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);
const kind = await getKind(def.kind);
if (b.attributes !== undefined) await checkAttributes(kind.id, { ...def.attributes, ...b.attributes });
if (b.attributes !== undefined) await checkAttributes(kind.id, { ...def.attributes, ...b.attributes });
if (!kind.stackable) {
if (!kind.stackable) {
@@ -299,13 +322,13 @@ function createInventory({ s, outbox, log = console }) {
@@ -299,13 +322,13 @@ function createInventory({ s, outbox, log = console }) {
async function equipped(subjects) {
async function equipped(subjects) {
const list = [...new Set((subjects || []).map(String))].filter((x) => SUBJECT_RE.test(x)).slice(0, 100);
const list = [...new Set((subjects || []).map(String))].filter((x) => SUBJECT_RE.test(x)).slice(0, 100);
if (!list.length) return [];
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
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]);
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 }]));
const by = new Map(list.map((x) => [x, { subject: x, slots: {}, updated_at: null }]));
for (const r of rows) {
for (const r of rows) {
const e = by.get(r.owner_subject);
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;
if (!e.updated_at || r.updated_at > e.updated_at) e.updated_at = r.updated_at;
}
}
const fallback = s.iso();
const fallback = s.iso();
@@ -341,10 +364,43 @@ function createInventory({ s, outbox, log = console }) {
@@ -341,10 +364,43 @@ function createInventory({ s, outbox, log = console }) {
return (await equipped([subject]))[0];
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 {
return {
listKinds, getKind, upsertKind, listDefinitions, getDefinition, byAlias, createDefinition, updateDefinition,
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 };
@@ -26,6 +26,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@ -26,6 +26,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const NAV = [
const NAV = [
{ label: 'Every item', href: '/items' },
{ label: 'Every item', href: '/items' },
{ label: 'Your inventory', href: '/me' },
{ label: 'Your inventory', href: '/me' },
+ { label: 'Workshop', href: '/workshop' },
{ label: 'What shipped', href: '/updates' },
{ label: 'What shipped', href: '/updates' },
];
];
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,221 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -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 };
@@ -81,6 +81,25 @@ async function startNetworkStub() {
@@ -81,6 +81,25 @@ async function startNetworkStub() {
assert.strictEqual(stub.calls.filter((c) => c.url.includes('/confirmations')).length, 1);
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 () => {
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 });
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);
assert.strictEqual((await deliver(event, { secret: `whsec_${'mismatch'.repeat(5)}` })).status, 401);
@@ -38,7 +38,7 @@ async function boot(opts = {}) {
@@ -38,7 +38,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
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();
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 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}`;
const base = `http://127.0.0.1:${server.address().port}`;
@@ -24,9 +24,9 @@ const { boot, check, done } = require('./helpers/boot');
@@ -24,9 +24,9 @@ const { boot, check, done } = require('./helpers/boot');
let granted;
let granted;
try {
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;
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);
for (const k of kinds) valid('inventory.kind@1', k);
const defs = (await api('/definitions?limit=500')).json().definitions;
const defs = (await api('/definitions?limit=500')).json().definitions;
assert.strictEqual(defs.length, 70);
assert.strictEqual(defs.length, 70);
@@ -37,7 +37,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -37,7 +37,7 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(crown.rarity, 'epic');
assert.strictEqual(crown.rarity, 'epic');
// A second boot on the same database seeds nothing again.
// A second boot on the same database seeds nothing again.
const { seed } = require('../server/inventory/seed');
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 () => {
await check('an issuer grants within its namespace, idempotently; a non-stackable item already owned answers the one held', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,147 @@
@@ -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('|'|')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();
+})();