openvibers/openvibe.quest Commits

Quest 0.3.0: item rewards — six quests also give one of Live's items in OpenVibe.Inventory (Sparkle, Hearts, Basic Cap, Fire, Ice and Rainbow Name), granted with Quest's own token after the completion commits, retried, and given to earlier finishers too (#3)

OP OpenVibers committed 9 Oct 2026 · 42bbfef36f · 20 files, +353 −23

Changes

ddiff --git a/.env.example b/.env.example
iindex cf1e318..0f66898 100644
---- a/.env.example
++++ b/.env.example
@@@ -43,3 +43,11 @@ QUEST_COINS=off
#QUEST_COINS_DAILY_CAP=50
#QUEST_COINS_GLOBAL_DAILY_CAP=5000
#QUEST_COINS_RETRY_MS=300000
++
++# Item rewards (server/quests/items.js): six quests also give one of Live's items in OpenVibe.Inventory, granted with
++# this service's client credentials (audience openvibe.inventory, inventory.item.grant; Live names Quest a grantor of
++# those items). On by default: an item is free and never money. A failed grant is retried, and so is every completion
++# from before items, so earlier finishers get theirs.
++#QUEST_ITEMS=on
++#OV_INVENTORY_INTERNAL_URL=http://127.0.0.1:5030
++#QUEST_ITEMS_RETRY_MS=300000
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 4bd8462..c6e75a9 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,17 @@
What changed in OpenVibe.Quest, newest first. Each site also publishes its patch notes at /updates.
++## 0.3.0 — 2026-10-09
++
++- **Item rewards** (plan T21): six quests also give one of Live's items for the person's OpenVibe.Inventory: say hello
++ in chat (Sparkle), follow someone (Hearts), go live (Basic Cap), stream three times (Fire Name), start a thread (Ice
++ Name), improve a wiki page (Rainbow Name).
++ - **How:** `server/quests/items.js` grants each in Inventory after the completion commits, with Quest's own token,
++ origin `earned` and the completion id as the idempotency key.
++ - **Failures:** a failed grant is recorded (`migrations/0004_item_rewards.sql`) and retried by a timer, which also
++ reaches completions from before this release.
++ - **Pages:** the quest pages, `/me` and `/api/v1/quests` name the item.
++
## 0.2.0 — 2026-10-08
- **The product.** One quest log across the whole network:
ddiff --git a/README.md b/README.md
iindex 512c0f9..618255e 100644
---- a/README.md
++++ b/README.md
@@@ -28,6 +28,7 @@ subscribes to.
| Events consumer | [server/events-consumer.js](server/events-consumer.js) | `POST /internal/events`, exactly as OpenVibe.Space does it: raw body, `parseDelivery(…, { requireV2: true })`, the `openvibe-sdk` inbox for once-only application, forwarding headers refused, 503 without `QUEST_EVENTS_SECRET`. It subscribes to the catalog's events at boot (idempotent, off without `QUEST_EVENTS_URL`) |
| What moves a quest | [server/quests/apply.js](server/quests/apply.js) | The person is taken from the field the event's own schema defines (`channel.subject`, `follower`, `author`, `user_subject`, `player.subject`, `owner`; the envelope actor only where the schema says the actor *is* that person). No subject, a `gst_` guest, or a service/app/mod principal → ignored, nothing written |
| Quest log | [server/quests/store.js](server/quests/store.js), [migrations/0002_quest.sql](migrations/0002_quest.sql) | Progress per step, one completion per person per quest (unique), one badge per person, and the Events receipts. Two deliveries of the same event change nothing; so does a replay of the whole request |
++| Item rewards | [server/quests/items.js](server/quests/items.js) | Six quests also give one of Live's items: Sparkle, Hearts, Basic Cap, Fire Name, Ice Name and Rainbow Name. Each is granted in OpenVibe.Inventory with Quest's own token (Live names Quest a grantor of those items, ADR-054 §3) as `earned`, keyed by the completion id. Failures and completions from before items are retried by a timer. `QUEST_ITEMS=off` turns it off |
| OpenCoins | [server/quests/coins.js](server/quests/coins.js) | Credits a completion through OpenVibe.Network's wallet with `idempotency_key = <completion id>`. Off by default, per-person and network-wide daily caps, failures recorded with their reason and retried by a timer |
| API | [server/http/api.js](server/http/api.js) | `GET /quests`, `GET /quests/:id`, `GET /me/quests`, `GET /profiles/:subject/badges` (see the table below) |
| Pages | [server/http/pages.js](server/http/pages.js), [server/render/quests.js](server/render/quests.js) | Home (the quests grouped by site, how progress works), a quest, your quest log (plain `<progress>` bars), the rules, the update log — all server-rendered, all readable without JavaScript |
ddiff --git a/migrations/0004_item_rewards.sql b/migrations/0004_item_rewards.sql
nnew file mode 100644
iindex 0000000..0c7910a
---- /dev/null
++++ b/migrations/0004_item_rewards.sql
@@@ -0,0 +1,10 @@
++-- phase: expand
++-- Item rewards (plan T21): six quests also give one of Live's items, granted in OpenVibe.Inventory after the
++-- completion commits (server/quests/items.js), the way OpenCoins are. item_state is NULL until decided, then
++-- 'granted' (item_instance is the inv_… id) or 'failed' (item_error says why; the retry timer comes back to it).
++-- A completion from before this release has NULL too, so the timer gives earlier finishers their item. Additive.
++ALTER TABLE quest_completions ADD COLUMN IF NOT EXISTS item_state text COLLATE "C";
++ALTER TABLE quest_completions ADD COLUMN IF NOT EXISTS item_instance text COLLATE "C";
++ALTER TABLE quest_completions ADD COLUMN IF NOT EXISTS item_error text COLLATE "C";
++ALTER TABLE quest_completions ADD COLUMN IF NOT EXISTS item_at text COLLATE "C";
++ALTER TABLE quest_completions ADD COLUMN IF NOT EXISTS item_attempts integer NOT NULL DEFAULT 0;
ddiff --git a/package-lock.json b/package-lock.json
iindex 9f2207c..f8d3309 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-quest",
-- "version": "0.2.0",
++ "version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-quest",
-- "version": "0.2.0",
++ "version": "0.3.0",
"license": "AGPL-3.0-only",
"dependencies": {
"cookie-parser": "^1.4.7",
ddiff --git a/package.json b/package.json
iindex 56e8c5d..7efccd5 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-quest",
-- "version": "0.2.0",
++ "version": "0.3.0",
"private": true,
"description": "OpenVibe.Quest — Quests across the whole network.",
"main": "server/index.js",
ddiff --git a/public/css/app.css b/public/css/app.css
iindex 0cc3991..eb2a4ef 100644
---- a/public/css/app.css
++++ b/public/css/app.css
@@@ -72,6 +72,8 @@ p.lede { font-size: 1.05rem; color: var(--text-secondary, #96a7c2); max-width: 7
.reward-badge, .reward-coins { font-size: .78rem; padding: 3px 10px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); color: var(--text-secondary, #96a7c2); }
.reward-badge strong { color: var(--text-primary, #e6edf7); }
.reward-coins { color: var(--warning, #f59e0b); border-color: color-mix(in srgb, var(--warning, #f59e0b) 40%, transparent); }
++.reward-item { font-size: .78rem; padding: 3px 10px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--accent, #3b82f6) 45%, transparent); color: var(--text-primary, #e6edf7); background: color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent); }
++.reward-item small { color: var(--text-muted, #7386a3); }
.reward-coins.soon { color: var(--text-muted, #7386a3); border-style: dashed; border-color: var(--border-light, #2c3d5c); }
@media (prefers-reduced-motion: reduce) { .quest { transition: none; } }
.steps { margin: .4rem 0; }
ddiff --git a/server/app.js b/server/app.js
iindex fc3daa6..c255809 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -29,6 +29,7 @@ const { createPrincipal } = require('./http/principal');
const { createApi } = require('./http/api');
const { createPageRoutes } = require('./http/pages');
const { createCoins } = require('./quests/coins');
++const { createItems } = require('./quests/items');
const { createEventsConsumer } = require('./events-consumer');
const accountDataLib = require('./identity/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
@@@ -54,6 +55,7 @@ async function createApp(opts = {}) {
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const principal = createPrincipal({ config, keys });
const coins = createCoins({ config, s, fetchImpl, log });
++ const items = createItems({ config, s, fetchImpl, log });
// Account export and deletion (ADR-033, ./identity/account-data.js): the three tables that hold a person's rows.
// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;
// a test injects a stand-in through opts.accountSend. Without a client secret the service has no way to push a
@@@ -63,8 +65,8 @@ async function createApp(opts = {}) {
const accountSend = opts.accountSend || (config.oauth.clientSecret
? 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: Quest cannot answer account events'); });
-- const eventsConsumer = createEventsConsumer({ db: s.db, s, secrets: config.events.secrets, coins, accountData, accountSend, now: s.now, log });
-- const ctx = { config, s, keys, sso, principal, coins, eventsConsumer, accountData, accountSend, log };
++ const eventsConsumer = createEventsConsumer({ db: s.db, s, secrets: config.events.secrets, coins, items, accountData, accountSend, now: s.now, log });
++ const ctx = { config, s, keys, sso, principal, coins, items, eventsConsumer, accountData, accountSend, log };
const app = express();
app.disable('x-powered-by');
ddiff --git a/server/config.js b/server/config.js
iindex fc98200..16c84a7 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -72,6 +72,15 @@ function load(env = process.env) {
globalDailyCap: Math.max(0, int(env.QUEST_COINS_GLOBAL_DAILY_CAP, 5000)),
retryMs: Math.max(10_000, int(env.QUEST_COINS_RETRY_MS, 300_000)),
},
++
++ // Item rewards (server/quests/items.js): some quests also give one of Live's items, granted in
++ // OpenVibe.Inventory with this service's own token (Live names Quest a grantor of those items, ADR-054 §3).
++ // On by default: an item is free and never money. QUEST_ITEMS=off records nothing and grants nothing.
++ items: {
++ mode: String(env.QUEST_ITEMS || 'on').toLowerCase() === 'off' ? 'off' : 'on',
++ inventoryUrl: trim(env.OV_INVENTORY_INTERNAL_URL || 'http://127.0.0.1:5030'),
++ retryMs: Math.max(10_000, int(env.QUEST_ITEMS_RETRY_MS, 300_000)),
++ },
};
}
ddiff --git a/server/events-consumer.js b/server/events-consumer.js
iindex c2dbd2d..89ec94d 100644
---- a/server/events-consumer.js
++++ b/server/events-consumer.js
@@@ -41,7 +41,7 @@ const CONSUMER = 'quest';
const TOPICS = Object.freeze([...catalog.EVENT_TYPES, ...accountDataLib.TOPICS]);
const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
--function createEventsConsumer({ db, s, secrets = [], coins = null, accountData = null, accountSend = null, now = () => Date.now(), log = console }) {
++function createEventsConsumer({ db, s, secrets = [], coins = null, items = null, accountData = null, accountSend = null, now = () => Date.now(), log = console }) {
const keys = (secrets || []).filter((x) => typeof x === 'string' && x.length >= 32);
// Receipts (quest_event_inbox) are in migrations/0002_quest.sql.
const inbox = createPgInbox(db, { table: 'quest_event_inbox', now });
@@@ -95,6 +95,8 @@ function createEventsConsumer({ db, s, secrets = [], coins = null, accountData =
// OpenCoins are settled after the transaction: the completion is committed first, so a wallet outage
// cannot lose (or roll back) a completion the person really earned. A failure is recorded and retried.
if (coins) for (const completion of outcome.completed) await coins.settle(completion);
++ // An item reward the same way (server/quests/items.js): granted in OpenVibe.Inventory after the commit.
++ if (items) for (const completion of outcome.completed) await items.settle(completion);
return res.json({
event_id: event.event_id, duplicate: false, outcome: outcome.outcome,
completed: outcome.completed.map((c) => c.id),
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 5ea7dba..37782a7 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -30,7 +30,7 @@ const questWire = (q) => ({
title: q.title,
why: q.why,
steps: q.steps.map((s) => ({ event_type: s.event_type, count: s.count, label: s.label })),
-- reward: { badge: { id: q.reward.badge.id, name: q.reward.badge.name }, coins: q.reward.coins },
++ reward: { badge: { id: q.reward.badge.id, name: q.reward.badge.name }, coins: q.reward.coins, ...(q.reward.item ? { item: { issuer: q.reward.item.issuer, alias: q.reward.item.alias, name: q.reward.item.name, kind: q.reward.item.kind } } : {}) },
repeatable: q.repeatable,
url: `/quests/${q.id}`,
});
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
iindex ed3abf2..1b25b9a 100644
---- a/server/http/discovery.js
++++ b/server/http/discovery.js
@@@ -23,7 +23,7 @@ const PAGE_TEXT = {
'/': ['OpenVibe.Quest home', 'OpenVibe.Quest: Quests across the whole network. The quests, grouped by the site whose events each one counts.'],
'/how-it-works': ['How quests work', 'What OpenVibe.Quest counts, what it stores, and what its rewards are — badges and OpenCoins, never money and never Vibes.'],
'/updates': ['What shipped on OpenVibe.Quest', 'This site\'s update log, from the network changelog feed.'],
-- ...Object.fromEntries(catalog.all().map((q) => [`/quests/${q.id}`, [q.title, `${q.why} On ${(catalog.SITES[q.site] || {}).name || q.site}. Reward: the ${q.reward.badge.name} badge${q.reward.coins > 0 ? ` and ${q.reward.coins} OpenCoins` : ''}.`]])),
++ ...Object.fromEntries(catalog.all().map((q) => [`/quests/${q.id}`, [q.title, `${q.why} On ${(catalog.SITES[q.site] || {}).name || q.site}. Reward: the ${q.reward.badge.name} badge${q.reward.item ? `, the ${q.reward.item.name} (${q.reward.item.kind.toLowerCase()}) for your inventory` : ''}${q.reward.coins > 0 ? ` and ${q.reward.coins} OpenCoins` : ''}.`]])),
};
function dayOf(ts) {
@@@ -75,7 +75,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'The catalog (JSON)', url: abs('/api/v1/quests'), note: 'every quest, grouped by site' },
{ title: 'What shipped on OpenVibe.Quest', url: abs('/updates') },
] },
-- { title: 'The quests', links: catalog.all().map((q) => ({ title: q.title, url: abs(`/quests/${q.id}`), note: `${(catalog.SITES[q.site] || {}).name || q.site} — reward: the ${q.reward.badge.name} badge` })) },
++ { title: 'The quests', links: catalog.all().map((q) => ({ title: q.title, url: abs(`/quests/${q.id}`), note: `${(catalog.SITES[q.site] || {}).name || q.site} — reward: the ${q.reward.badge.name} badge${q.reward.item ? ` and the ${q.reward.item.name}` : ''}` })) },
{ title: 'Machine-readable', links: [
{ title: 'Sitemap', url: abs('/sitemap.xml') },
{ title: 'Full text for language models', url: abs('/llms-full.txt') },
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 4dc6848..a763efd 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -177,7 +177,7 @@ ${q.steps.map((step, i) => quests.progress(step.label, (seen.get(q.id) || new Ma
${finished.length ? html`<ul class="quest-list">${finished.map((q) => {
const c = done.get(q.id);
return html`<li class="quest"><a class="quest-title" href="/quests/${q.id}">${q.title}</a>
--<p class="quest-why">Completed ${String(c.completed_at).slice(0, 10)}. Badge: <strong>${q.reward.badge.name}</strong>. ${c.coins > 0 ? (c.coins_state === 'credited' ? html`<strong>${c.coins}</strong> OpenCoins credited.` : c.coins_state === 'skipped' ? html`${c.coins} OpenCoins ${coinsUi.coinsEnabled ? html`were not credited: ${c.coins_error || 'the daily cap was reached'}` : html`start soon`}.` : html`${c.coins} OpenCoins could not be credited yet — this is recorded and retried.`) : ''}</p></li>`;
++<p class="quest-why">Completed ${String(c.completed_at).slice(0, 10)}. Badge: <strong>${q.reward.badge.name}</strong>. ${q.reward.item ? (c.item_state === 'granted' ? html`${q.reward.item.emoji} <strong>${q.reward.item.name}</strong> is in <a href="https://inventory.openvibe.network/me">your inventory</a>. ` : c.item_state === 'failed' ? html`${q.reward.item.name} could not be given yet — this is recorded and retried. ` : html`${q.reward.item.name} is on its way to your inventory. `) : ''}${c.coins > 0 ? (c.coins_state === 'credited' ? html`<strong>${c.coins}</strong> OpenCoins credited.` : c.coins_state === 'skipped' ? html`${c.coins} OpenCoins ${coinsUi.coinsEnabled ? html`were not credited: ${c.coins_error || 'the daily cap was reached'}` : html`start soon`}.` : html`${c.coins} OpenCoins could not be credited yet — this is recorded and retried.`) : ''}</p></li>`;
})}</ul>` : html`<p class="muted">No quests completed yet.</p>`}
<h2>Your badges</h2>
${badges.length ? html`<ul class="badges">${badges.map((b) => html`<li><strong>${badge(b.badge_id).name}</strong> <small>${String(b.awarded_at).slice(0, 10)}</small></li>`)}</ul>
ddiff --git a/server/index.js b/server/index.js
iindex 2d7baa1..5ae52fc 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -37,8 +37,11 @@ async function start() {
const timers = [];
const coinsTimer = ctx.coins.start();
if (coinsTimer) timers.push(coinsTimer);
++ // Item rewards owed (a failed grant, or a completion from before items) are granted by the same kind of timer.
++ const itemsTimer = ctx.items.start();
++ if (itemsTimer) timers.push(itemsTimer);
-- const extra = [() => { if (subscriptions) subscriptions.stop(); }, () => ctx.coins.stop()];
++ const extra = [() => { if (subscriptions) subscriptions.stop(); }, () => ctx.coins.stop(), () => ctx.items.stop()];
createLifecycle({ server, ctx, timers, extra });
return { server, ctx };
}
ddiff --git a/server/quests/catalog.js b/server/quests/catalog.js
iindex e1bb5fa..e49af70 100644
---- a/server/quests/catalog.js
++++ b/server/quests/catalog.js
@@@ -16,7 +16,9 @@
* title one line, a thing a person would say
* why one line on why it is worth doing (never a promise the sites do not keep)
* steps [{ event_type, count, label }] — every step must reach its count
-- * reward { badge: { id, name }, coins } — a text badge label (no emoji) and OpenCoins (loyalty, not money)
++ * reward { badge: { id, name }, coins, item? } — a text badge label (no emoji), OpenCoins (loyalty, not money)
++ * and, for some, an item for the person's OpenVibe.Inventory: { issuer, alias, name, kind, emoji }, one of
++ * Live's items that Live lets Quest give (ADR-054 §3 grantors; server/quests/items.js grants it)
* repeatable false: a quest completes once per person, and its badge is awarded once
*
* Community-made quests (the product's third pillar) are not here yet: they need moderation, which this release
@@@ -42,7 +44,7 @@ const QUESTS = Object.freeze([
title: 'Go live once',
why: 'A channel with nothing on it is a promise; one broadcast makes it real.',
steps: [{ event_type: 'live.stream.started', count: 1, label: 'Start a stream' }],
-- reward: { badge: { id: 'first-broadcast', name: 'First Broadcast' }, coins: 10 },
++ reward: { badge: { id: 'first-broadcast', name: 'First Broadcast' }, coins: 10, item: { issuer: 'service:live', alias: 'hat_basic_cap', name: 'Basic Cap', kind: 'Hat', emoji: '🧢' } },
repeatable: false,
},
{
@@@ -51,7 +53,7 @@ const QUESTS = Object.freeze([
title: 'Stream three times',
why: 'Three broadcasts is a habit; the first one is only an attempt.',
steps: [{ event_type: 'live.stream.started', count: 3, label: 'Start a stream' }],
-- reward: { badge: { id: 'regular-broadcaster', name: 'Regular Broadcaster' }, coins: 25 },
++ reward: { badge: { id: 'regular-broadcaster', name: 'Regular Broadcaster' }, coins: 25, item: { issuer: 'service:live', alias: 'fx_fire', name: 'Fire Name', kind: 'Name effect', emoji: '🔥' } },
repeatable: false,
},
{
@@@ -60,7 +62,7 @@ const QUESTS = Object.freeze([
title: 'Follow someone',
why: 'The network is a list of people you chose to hear from.',
steps: [{ event_type: 'network.follow.created', count: 1, label: 'Follow a channel or a person' }],
-- reward: { badge: { id: 'first-follow', name: 'First Follow' }, coins: 5 },
++ reward: { badge: { id: 'first-follow', name: 'First Follow' }, coins: 5, item: { issuer: 'service:live', alias: 'px_hearts', name: 'Hearts', kind: 'Particle effect', emoji: '💖' } },
repeatable: false,
},
{
@@@ -69,7 +71,7 @@ const QUESTS = Object.freeze([
title: 'Start a forum thread',
why: 'Every thread on the forum started as somebody\'s question.',
steps: [{ event_type: 'community.thread.created', count: 1, label: 'Start a thread' }],
-- reward: { badge: { id: 'thread-starter', name: 'Thread Starter' }, coins: 10 },
++ reward: { badge: { id: 'thread-starter', name: 'Thread Starter' }, coins: 10, item: { issuer: 'service:live', alias: 'fx_ice', name: 'Ice Name', kind: 'Name effect', emoji: '❄️' } },
repeatable: false,
},
{
@@@ -96,7 +98,7 @@ const QUESTS = Object.freeze([
title: 'Say hello in chat',
why: 'A public room is the fastest way to meet the people watching the same stream.',
steps: [{ event_type: 'chat.room.message.created', count: 1, label: 'Send a message in a public room' }],
-- reward: { badge: { id: 'said-hello', name: 'Said Hello' }, coins: 5 },
++ reward: { badge: { id: 'said-hello', name: 'Said Hello' }, coins: 5, item: { issuer: 'service:live', alias: 'px_sparkle', name: 'Sparkle', kind: 'Particle effect', emoji: '✨' } },
repeatable: false,
},
{
@@@ -105,7 +107,7 @@ const QUESTS = Object.freeze([
title: 'Improve a wiki page',
why: 'One corrected sentence is a page someone else reads correctly.',
steps: [{ event_type: 'wiki.revision.created', count: 1, label: 'Save a revision' }],
-- reward: { badge: { id: 'wiki-editor', name: 'Wiki Editor' }, coins: 15 },
++ reward: { badge: { id: 'wiki-editor', name: 'Wiki Editor' }, coins: 15, item: { issuer: 'service:live', alias: 'fx_rainbow', name: 'Rainbow Name', kind: 'Name effect', emoji: '🌈' } },
repeatable: false,
},
{
ddiff --git a/server/quests/items.js b/server/quests/items.js
nnew file mode 100644
iindex 0000000..e64a5c7
---- /dev/null
++++ b/server/quests/items.js
@@@ -0,0 +1,129 @@
++'use strict';
++
++/**
++ * Item rewards: granting a completion's item in OpenVibe.Inventory (plan T21).
++ *
++ * Some quests give one of Live's items (catalog.js reward.item: Live's alias, the name and emoji the pages show).
++ * Live is the item's issuer and names Quest a grantor of it (ADR-054 §3 amendment, Contracts 0.123.0), so Quest
++ * grants it itself: POST /api/v1/grants with origin `earned`, the completion id (qcp_…) as the idempotency key and
++ * the quest as the reason. A retry of a grant that did land answers the same instance, so nobody gets two. Nothing
++ * is bought, sold or traded, and an item is never money.
++ *
++ * Auth: this service's own client credentials (`quest`) through openvibe-contracts' cached token client, for
++ * audience openvibe.inventory and inventory.item.grant. Inventory's definition ids are read once by alias
++ * (GET /api/v1/definitions?issuer=service:live, public) and kept for an hour.
++ *
++ * Like OpenCoins, a grant runs after the completion commits: an Inventory outage never holds up a delivery or rolls
++ * back a completion. A failure is recorded (item_state 'failed' with a short reason) and the retry timer comes back
++ * to it; the timer also picks up completions from before this release (item_state NULL), so everyone who already
++ * finished a quest gets its item. QUEST_ITEMS=off turns all of it off.
++ */
++const { serviceAuth } = require('openvibe-contracts');
++const store = require('./store');
++const catalog = require('./catalog');
++
++const AUDIENCE = 'openvibe.inventory';
++const SCOPES = 'inventory.item.grant';
++const TIMEOUT_MS = 5000;
++const DEFS_TTL_MS = 60 * 60 * 1000;
++
++function createItems({ config, s, fetchImpl = globalThis.fetch, log = console, now = () => Date.now() }) {
++ const enabled = config.items.mode === 'on';
++ const base = String(config.items.inventoryUrl || '').replace(/\/+$/, '');
++ const networkBase = String(config.networkInternalUrl || '').replace(/\/+$/, '');
++ const tokens = serviceAuth.createTokenClient({
++ tokenUrl: `${networkBase}/oauth/token`,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience: AUDIENCE,
++ scope: SCOPES,
++ fetchImpl,
++ });
++ const rewarded = catalog.QUESTS.filter((q) => q.reward && q.reward.item).map((q) => q.id);
++ let defs = null; // { at, byAlias: Map(alias → itd_ id) }
++ let timer = null;
++ const stats = { granted: 0, failed: 0, attempts: 0, last_at: null };
++
++ async function call(method, path, { body, auth = false } = {}) {
++ const headers = { Accept: 'application/json', ...(auth ? await tokens.authHeaders() : {}) };
++ if (body) headers['Content-Type'] = 'application/json';
++ const res = await fetchImpl(`${base}/api/v1${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(TIMEOUT_MS) });
++ const json = await res.json().catch(() => null);
++ if (!res.ok) {
++ // Inventory's own problem code (inventory.not_issuer, inventory.not_published), never the token.
++ const err = new Error(`Inventory ${res.status}${json && json.code ? `: ${json.code}` : ''}`);
++ err.status = res.status;
++ throw err;
++ }
++ return json || {};
++ }
++
++ /** The itd_ id of one of an issuer's items, by its alias (Live's item id). */
++ async function definitionId(issuer, alias) {
++ if (!defs || now() - defs.at > DEFS_TTL_MS || !defs.byAlias.has(`${issuer}|${alias}`)) {
++ const out = await call('GET', `/definitions?issuer=${encodeURIComponent(issuer)}&limit=500`);
++ const byAlias = new Map();
++ for (const d of out.definitions || []) for (const a of d.aliases || []) byAlias.set(`${d.issuer}|${a}`, d.id);
++ defs = { at: now(), byAlias };
++ }
++ return defs.byAlias.get(`${issuer}|${alias}`) || null;
++ }
++
++ /** Grant one completion's item; → the instance id. Throws with a short, safe reason. */
++ async function grant(completion) {
++ const quest = catalog.byId(completion.quest_id);
++ const item = quest && quest.reward.item;
++ if (!item) throw new Error(`no item reward for ${completion.quest_id}`);
++ const id = await definitionId(item.issuer, item.alias);
++ if (!id) throw new Error(`OpenVibe.Inventory has no ${item.alias} from ${item.issuer}`);
++ const out = await call('POST', '/grants', {
++ auth: true,
++ body: { definition_id: id, subject: completion.subject, idempotency_key: completion.id, origin: 'earned', reason: `Quest: ${quest.title}`.slice(0, 200) },
++ });
++ return out.instance && out.instance.id;
++ }
++
++ /** Decide one completion's item now: grant it, or record the failure for the retry timer. Never throws. */
++ async function settle(completion) {
++ if (!enabled || !completion) return null;
++ const quest = catalog.byId(completion.quest_id);
++ if (!quest || !quest.reward.item) return null;
++ if (completion.item_state === 'granted') return completion;
++ stats.attempts++;
++ try {
++ const instance = await grant(completion);
++ stats.granted++; stats.last_at = new Date(now()).toISOString();
++ return store.setItem(s.db, completion.id, { state: 'granted', instance, error: null, at: new Date(now()).toISOString(), attempts: 1 });
++ } catch (err) {
++ stats.failed++;
++ const message = String((err && err.message) || err).slice(0, 200);
++ log.warn(`[Items] ${completion.id} (${completion.quest_id}) not granted: ${message}`);
++ return store.setItem(s.db, completion.id, { state: 'failed', error: message, attempts: 1 });
++ }
++ }
++
++ /** Come back to every completion whose item is still owed: a failure, or a completion from before items. */
++ async function retryPending({ limit = 50 } = {}) {
++ if (!enabled) return { tried: 0, granted: 0, failed: 0 };
++ const rows = await store.owedItems(s.db, rewarded, limit);
++ const out = { tried: rows.length, granted: 0, failed: 0 };
++ for (const row of rows) {
++ const after = await settle(row);
++ if (after && after.item_state === 'granted') out.granted++; else if (after) out.failed++;
++ }
++ if (rows.length) log.log(`[Items] retried ${rows.length}: ${out.granted} granted, ${out.failed} still failing`);
++ return out;
++ }
++
++ function start({ intervalMs = config.items.retryMs } = {}) {
++ if (!enabled || timer) return null;
++ timer = setInterval(() => { retryPending().catch((err) => log.warn('[Items] retry failed:', err.message)); }, intervalMs);
++ if (timer.unref) timer.unref();
++ return timer;
++ }
++ function stop() { if (timer) clearInterval(timer); timer = null; }
++
++ return { enabled, settle, retryPending, start, stop, grant, status: () => ({ enabled, ...stats }) };
++}
++
++module.exports = { createItems, AUDIENCE, SCOPES };
ddiff --git a/server/quests/store.js b/server/quests/store.js
iindex f383301..980a187 100644
---- a/server/quests/store.js
++++ b/server/quests/store.js
@@@ -102,6 +102,21 @@ const creditedAll = async (db, day) => Number(await db.value(
WHERE coins_state = 'credited' AND coins_at IS NOT NULL AND left(coins_at, 10) = $1::text`,
[day]));
++/** Record what happened to a completion's item reward. */
++const setItem = (db, id, { state, instance = null, error = null, at = null, attempts = 0 }) => db.maybe(
++ `UPDATE quest_completions
++ SET item_state = $2, item_instance = COALESCE($3, item_instance), item_error = $4, item_at = $5,
++ item_attempts = item_attempts + $6
++ WHERE id = $1
++ RETURNING *`,
++ [id, state, instance, error, at, attempts]);
++
++/** Completions of the given quests whose item is still owed (never decided, or failed), oldest first. */
++const owedItems = (db, questIds, limit = 50) => (questIds.length ? db.many(
++ `SELECT * FROM quest_completions WHERE quest_id = ANY($1::text[]) AND (item_state IS NULL OR item_state = 'failed')
++ ORDER BY completed_at, id LIMIT $2`,
++ [questIds, limit]) : Promise.resolve([]));
++
/** How many people have a badge — the count a quest page can show without naming anyone. */
const badgeHolders = async (db, badgeId) => Number(await db.value('SELECT count(*) FROM quest_badges WHERE badge_id = $1', [badgeId]));
@@@ -111,4 +126,5 @@ module.exports = {
completion, completionsFor, completionCount, complete,
awardBadge, badgesFor, badgesOf, badgeHolders,
setCoins, owedCoins, creditedTo, creditedAll,
++ setItem, owedItems,
};
ddiff --git a/server/render/quests.js b/server/render/quests.js
iindex 5c074b7..96e6d91 100644
---- a/server/render/quests.js
++++ b/server/render/quests.js
@@@ -9,15 +9,18 @@ const catalog = require('../quests/catalog');
const { html } = require('./html');
/**
-- * What a quest pays: its badge (a text label) and its OpenCoins. When OpenCoins are off the reward says so
++ * What a quest pays: its badge (a text label), for some an item (OpenVibe.Inventory) and its OpenCoins. When OpenCoins are off the reward says so
* instead of promising points that do not move — the sentence the product uses everywhere.
*/
function reward(quest, { coinsEnabled = false } = {}) {
const badge = quest.reward.badge.name;
const badgeChip = html`<span class="reward-badge">Badge: <strong>${badge}</strong></span>`;
-- if (quest.reward.coins <= 0) return html`<span class="reward">${badgeChip}</span>`;
-- if (!coinsEnabled) return html`<span class="reward">${badgeChip}<span class="reward-coins soon">OpenCoins rewards start soon</span></span>`;
-- return html`<span class="reward">${badgeChip}<span class="reward-coins"><strong>${quest.reward.coins}</strong> OpenCoins</span></span>`;
++ // An item for the person's OpenVibe.Inventory (server/quests/items.js), worn in chat and on their profile.
++ const it = quest.reward.item;
++ const itemChip = it ? html`<span class="reward-item" title="${it.kind} for your OpenVibe inventory"><span aria-hidden="true">${it.emoji}</span> <strong>${it.name}</strong> <small>${it.kind.toLowerCase()}</small></span>` : '';
++ if (quest.reward.coins <= 0) return html`<span class="reward">${badgeChip}${itemChip}</span>`;
++ if (!coinsEnabled) return html`<span class="reward">${badgeChip}${itemChip}<span class="reward-coins soon">OpenCoins rewards start soon</span></span>`;
++ return html`<span class="reward">${badgeChip}${itemChip}<span class="reward-coins"><strong>${quest.reward.coins}</strong> OpenCoins</span></span>`;
}
/** A badge's colour, fixed by its quest id, so a badge looks the same wherever it is shown. */
ddiff --git a/test/items.test.js b/test/items.test.js
nnew file mode 100644
iindex 0000000..90febfb
---- /dev/null
++++ b/test/items.test.js
@@@ -0,0 +1,132 @@
++'use strict';
++/**
++ * Item rewards (server/quests/items.js, plan T21): six quests also give one of Live's items, granted in a stand-in
++ * OpenVibe.Inventory with Quest's own token (audience openvibe.inventory, inventory.item.grant) after the completion
++ * commits: origin earned, the completion id as the idempotency key, the quest as the reason. A refusal is recorded
++ * and retried; a completion from before items is granted by the retry; a quest without an item asks nothing;
++ * QUEST_ITEMS=off asks nothing at all. The pages and the API name the item.
++ */
++const assert = require('assert');
++const http = require('http');
++const { ids } = require('openvibe-contracts');
++const { signDeliveryHeaders } = require('openvibe-sdk/events');
++const { boot, check, done } = require('./helpers/boot');
++
++const SECRET = 'quest-events-secret-0123456789abcdef';
++const SPARKLE = 'itd_01JZ00000000000000000001S1';
++const HEARTS = 'itd_01JZ00000000000000000001H1';
++const iso = () => new Date().toISOString();
++const chatMessage = (user_subject) => ({
++ event_id: ids.newId('event'), event_type: 'chat.room.message.created', version: 1, source: 'chat',
++ actor: { type: 'user', id: user_subject }, timestamp: iso(), subject: { type: 'thing', id: '1' },
++ payload: { message_id: 12, room: { id: 'room_1', slug: 'general', name: 'General' }, text: 'hello', created_at: iso(), user_subject, username: 'liv', display_name: 'Liv' },
++});
++const deliver = (t, event) => {
++ const raw = JSON.stringify({ event, seq: 1 });
++ return t.get('/internal/events', { method: 'POST', body: raw, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(raw, SECRET) } });
++};
++const claims = (auth) => JSON.parse(Buffer.from(String(auth).replace(/^Bearer /, '').split('.')[1], 'base64url').toString('utf8'));
++
++function stubInventory() {
++ const st = { grants: [], down: false, instances: 0 };
++ const server = http.createServer((req, res) => {
++ let raw = '';
++ req.on('data', (c) => { raw += c; });
++ req.on('end', () => {
++ const json = (status, o) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
++ const u = new URL(req.url, 'http://x');
++ if (u.pathname === '/api/v1/definitions') return json(200, { definitions: [{ id: SPARKLE, issuer: 'service:live', aliases: ['px_sparkle'] }, { id: HEARTS, issuer: 'service:live', aliases: ['px_hearts'] }] });
++ if (u.pathname === '/api/v1/grants' && req.method === 'POST') {
++ if (st.down) return json(503, { code: 'unavailable' });
++ const body = JSON.parse(raw);
++ st.grants.push({ body, auth: req.headers.authorization });
++ const prior = st.grants.find((g) => g.body.idempotency_key === body.idempotency_key && g.instance);
++ const instance = prior ? prior.instance : `inv_01JZ00000000000000000000${String(++st.instances).padStart(2, '0')}`;
++ st.grants[st.grants.length - 1].instance = instance;
++ return json(prior ? 200 : 201, { instance: { id: instance, definition_id: body.definition_id, owner: body.subject, origin: body.origin, state: 'owned' }, created: !prior });
++ }
++ return json(404, { code: 'route.not_found' });
++ });
++ });
++ return new Promise((r) => server.listen(0, '127.0.0.1', () => r({ st, url: `http://127.0.0.1:${server.address().port}`, close: () => server.close() })));
++}
++
++(async () => {
++ const inv = await stubInventory();
++ try {
++ await check('saying hello in chat gives the Sparkle: earned, keyed by the completion, with Quest\'s token', async () => {
++ const t = await boot({ env: { QUEST_EVENTS_SECRET: SECRET, OV_INVENTORY_INTERNAL_URL: inv.url } });
++ try {
++ const u = t.network.addUser('liv');
++ const r = await deliver(t, chatMessage(u.subject));
++ assert.strictEqual(r.status, 200, r.text);
++ const c = (await t.ctx.s.db.many("SELECT * FROM quest_completions WHERE subject = $1 AND quest_id = 'say-hello-in-chat'", [u.subject]))[0];
++ assert.ok(c, 'the quest completed');
++ const g = inv.st.grants.find((x) => x.body.idempotency_key === c.id);
++ assert.deepStrictEqual(g.body, { definition_id: SPARKLE, subject: u.subject, idempotency_key: c.id, origin: 'earned', reason: 'Quest: Say hello in chat' });
++ const tok = claims(g.auth);
++ assert.deepStrictEqual([tok.sub, tok.aud, tok.cap], ['svc:quest', ['openvibe.inventory'], ['inventory.item.grant']]);
++ assert.deepStrictEqual([c.item_state, c.item_instance], ['granted', g.instance]);
++ // The person's page says it is in their inventory.
++ const me = await t.get('/me', { headers: { cookie: t.signIn(u) } });
++ assert.ok(me.text.includes('Sparkle') && me.text.includes('your inventory'), 'the /me page names the item');
++ } finally { await t.close(); }
++ });
++
++ await check('a refused grant is recorded and retried; a completion from before items is granted by the retry; no item, no call', async () => {
++ const t = await boot({ env: { QUEST_EVENTS_SECRET: SECRET, OV_INVENTORY_INTERNAL_URL: inv.url } });
++ try {
++ const u = t.network.addUser('ana');
++ inv.st.down = true;
++ await deliver(t, chatMessage(u.subject));
++ let c = (await t.ctx.s.db.many("SELECT * FROM quest_completions WHERE subject = $1 AND quest_id = 'say-hello-in-chat'", [u.subject]))[0];
++ assert.strictEqual(c.item_state, 'failed');
++ assert.match(c.item_error, /Inventory 503/);
++ inv.st.down = false;
++ // A completion recorded before this release: item_state NULL.
++ const old = 'qcp_01JZ0000000000000000000012';
++ await t.ctx.s.db.exec("INSERT INTO quest_completions (id, subject, quest_id, completed_at, coins, coins_state) VALUES ($1, $2, 'follow-someone', $3, 5, 'skipped')", [old, u.subject, iso()]);
++ const before = inv.st.grants.length;
++ const out = await t.ctx.items.retryPending();
++ assert.deepStrictEqual(out, { tried: 2, granted: 2, failed: 0 });
++ c = (await t.ctx.s.db.many("SELECT * FROM quest_completions WHERE subject = $1 AND quest_id = 'say-hello-in-chat'", [u.subject]))[0];
++ assert.strictEqual(c.item_state, 'granted');
++ const hearts = inv.st.grants.slice(before).find((g) => g.body.idempotency_key === old);
++ assert.strictEqual(hearts.body.definition_id, HEARTS, 'the earlier finisher gets their Hearts');
++ assert.deepStrictEqual(await t.ctx.items.retryPending(), { tried: 0, granted: 0, failed: 0 }, 'nothing owed now');
++ // A quest that gives no item is never asked about.
++ const none = 'qcp_01JZ0000000000000000000034';
++ await t.ctx.s.db.exec("INSERT INTO quest_completions (id, subject, quest_id, completed_at, coins, coins_state) VALUES ($1, $2, 'reply-in-a-thread', $3, 5, 'skipped')", [none, u.subject, iso()]);
++ assert.deepStrictEqual(await t.ctx.items.retryPending(), { tried: 0, granted: 0, failed: 0 });
++ } finally { await t.close(); }
++ });
++
++ await check('QUEST_ITEMS=off: nothing is granted, nothing retried, no timer', async () => {
++ const t = await boot({ env: { QUEST_EVENTS_SECRET: SECRET, OV_INVENTORY_INTERNAL_URL: inv.url, QUEST_ITEMS: 'off' } });
++ try {
++ const u = t.network.addUser('off');
++ const before = inv.st.grants.length;
++ await deliver(t, chatMessage(u.subject));
++ assert.strictEqual(inv.st.grants.length, before);
++ assert.deepStrictEqual(await t.ctx.items.retryPending(), { tried: 0, granted: 0, failed: 0 });
++ assert.strictEqual(t.ctx.items.start(), null);
++ } finally { await t.close(); }
++ });
++
++ await check('the quest pages and the API name the item', async () => {
++ const t = await boot({ env: { OV_INVENTORY_INTERNAL_URL: inv.url } });
++ try {
++ const page = await t.get('/quests/say-hello-in-chat');
++ assert.ok(page.text.includes('class="reward-item"') && page.text.includes('Sparkle'), 'the reward chip');
++ const api = (await t.get('/api/v1/quests')).json();
++ const q = (api.quests || api).find((x) => x.id === 'say-hello-in-chat');
++ assert.deepStrictEqual(q.reward.item, { issuer: 'service:live', alias: 'px_sparkle', name: 'Sparkle', kind: 'Particle effect' });
++ const none = (api.quests || api).find((x) => x.id === 'reply-in-a-thread');
++ assert.strictEqual(none.reward.item, undefined);
++ } finally { await t.close(); }
++ });
++ } finally {
++ inv.close();
++ }
++ done();
++})();
ddiff --git a/test/quests-api.test.js b/test/quests-api.test.js
iindex b99261e..27d3072 100644
---- a/test/quests-api.test.js
++++ b/test/quests-api.test.js
@@@ -35,7 +35,7 @@ const { render } = require('../server/render/html');
const goLive = body.quests.find((q) => q.id === 'go-live');
assert.strictEqual(goLive.title, 'Go live once');
assert.deepStrictEqual(goLive.steps, [{ event_type: 'live.stream.started', count: 1, label: 'Start a stream' }]);
-- assert.deepStrictEqual(goLive.reward, { badge: { id: 'first-broadcast', name: 'First Broadcast' }, coins: 10 });
++ assert.deepStrictEqual(goLive.reward, { badge: { id: 'first-broadcast', name: 'First Broadcast' }, coins: 10, item: { issuer: 'service:live', alias: 'hat_basic_cap', name: 'Basic Cap', kind: 'Hat' } });
assert.strictEqual(goLive.repeatable, false);
assert.strictEqual(body.coins.enabled, false, 'OpenCoins are off by default');
assert.strictEqual(body.coins.daily_cap, 50);