openvibers/openvibe.quest Commits

Quest: quests on Blog, Deals, Run and Inventory (publish a post, share a deal, vote on ten, run a job, wear an item) (#53)

OP OpenVibers committed 29 days ago · 11a9ec7af2 · 6 files, +94 −6

Changes

ddiff --git a/README.md b/README.md
iindex 20453ef..f66c624 100644
---- a/README.md
++++ b/README.md
@@@ -87,7 +87,7 @@ own** and produces no events; no `/api/v1` route is guarded by one ([server/http
| Piece | Where | What it does |
|---|---|---|
--| Catalog | [server/quests/catalog.js](server/quests/catalog.js) | 12 first-party quests in code, one per real thing a person can do, plus the whole list of events Quest listens to (and the one it does not). A quest may not count an event the catalog does not document: that fails at load |
++| Catalog | [server/quests/catalog.js](server/quests/catalog.js) | 35 first-party quests in code (the 18 of the Live setup series among them), one per real thing a person can do, plus the whole list of events Quest listens to (and the one it does not). A quest may not count an event the catalog does not document: that fails at load |
| 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 |
@@@ -129,6 +129,10 @@ OpenVibe.Events delivers an event when a site really does something. Quest count
| `games.player.joined` | OpenVibe.Games | the player's account subject (a guest is ignored) |
| `tools.job.created` | OpenVibe.Tools | the owner (an anonymous session is ignored) |
| `live.setup.task_completed` | OpenVibe.Live | `payload.user`, the streamer who finished a task on Live's go-live setup list |
++| `blog.post.published` | OpenVibe.Blog | the envelope actor (a schedule or a service publishing is nobody) |
++| `deals.offer.created` / `deals.vote.changed` | OpenVibe.Deals | the envelope actor: the submitter or the voter (an imported deal's actor is `svc:deals`) |
++| `run.job.succeeded` | OpenVibe.Run | `payload.requester` when it is a user (an app's or a service's job is nobody's) |
++| `inventory.item.equipped` | OpenVibe.Inventory | `payload.owner` |
A step can also name payload values to `match` (`{ task: 'emote' }`): the "Set up your channel" series is one
quest per Live setup task, plus one that counts all 18 tasks. The home page shows a series as one card; its page is
@@@ -142,7 +146,7 @@ coins are recorded once; a redelivery of the same event, or the whole request ag
| **OpenVibe.Events** (`openvibe.events`) | the domain events that move the log, `POST /internal/events`, loopback only | Signed deliveries only: `X-OpenVibe-Signature-V2` (HMAC-SHA256 over `<timestamp>.<raw body>`), a 300-second replay window, `requireV2`, the secret from `QUEST_EVENTS_SECRET` (32+ characters, comma-separated for rotation). A delivery that carries a forwarding header is refused: nginx never proxies this route. Once-only application is the `openvibe-sdk` inbox, in the same transaction as the writes |
| **openvibe-contracts** v0.190.0 | the shape of every event and of the two Network calls | Every field read is the one the schema at `contracts/events/payloads/<type>.v1.json` defines; no field is inferred and nothing about a person is taken from a name or from the request |
| **OpenVibe.Network** (`openvibe.network`) | resolving a subject to its wallet id, and crediting OpenCoins | `GET /internal/identity/resolve` (capability `identity.subject.resolve`, `identity.resolve-result@1`) gives `network_user_id`; `POST /internal/coins/credit` (capability `network.coins.credit`, body `network.coins-change-request@1`, answer `network.coins-balance-result@1`) credits, with `app_id: quest` — a service token may only name its own app id. Both use this service's own client credentials over the internal URL; no token is logged |
--| **OpenVibe sites** (Live, Community, Space, Chat, Wiki, Games, Tools, Network) | nothing is fetched from them | Quest makes no outbound request to any site, and no URL from a request ever decides one: the only host it calls is `OV_NETWORK_INTERNAL_URL` |
++| **OpenVibe sites** (Live, Community, Space, Chat, Wiki, Games, Tools, Network, Blog, Deals, Run, Inventory) | nothing is fetched from them | Quest makes no outbound request to any site, and no URL from a request ever decides one: the only host it calls is `OV_NETWORK_INTERNAL_URL` |
Rewards are badges and OpenCoins. Nothing here is money, and nothing here is Vibes.
ddiff --git a/server/quests/apply.js b/server/quests/apply.js
iindex e265edf..486a84b 100644
---- a/server/quests/apply.js
++++ b/server/quests/apply.js
@@@ -55,6 +55,14 @@ function personOf(event) {
return refUser(p.owner);
case 'live.setup.task_completed': // payload.user (the streamer; the envelope subject and actor are the same person)
return refUser(p.user);
++ case 'blog.post.published': // the envelope actor: the person who published (a schedule or a service is not one)
++ case 'deals.offer.created': // the envelope actor: the submitter (svc:deals for an imported deal)
++ case 'deals.vote.changed': // the envelope actor: the voter
++ return refUser(event.actor);
++ case 'run.job.succeeded': // payload.requester (identity.subject-ref@1: a user, service, app or mod)
++ return refUser(p.requester);
++ case 'inventory.item.equipped': // payload.owner (usr_…)
++ return isUser(p.owner) ? p.owner : null;
default:
return null;
}
ddiff --git a/server/quests/catalog.js b/server/quests/catalog.js
iindex eaa1197..3da3f1c 100644
---- a/server/quests/catalog.js
++++ b/server/quests/catalog.js
@@@ -37,6 +37,10 @@ const SITES = Object.freeze({
'openvibe.games': { name: 'OpenVibe.Games', what: 'the games platform' },
'openvibe.tools': { name: 'OpenVibe.Tools', what: 'the tools' },
'openvibe.space': { name: 'OpenVibe.Space', what: 'forums in a Space' },
++ 'openvibe.blog': { name: 'OpenVibe.Blog', what: 'blogs' },
++ 'openvibe.deals': { name: 'OpenVibe.Deals', what: 'deals the community shares and votes on' },
++ 'openvibe.run': { name: 'OpenVibe.Run', what: 'jobs run on nodes' },
++ 'inventory.openvibe.network': { name: 'OpenVibe.Inventory', what: 'the items you own across OpenVibe' },
});
/**
@@@ -155,6 +159,51 @@ const QUESTS = Object.freeze([
reward: { badge: { id: 'space-thread-starter', name: 'Space Thread Starter' }, coins: 10 },
repeatable: false,
},
++ {
++ id: 'publish-a-blog-post',
++ site: 'openvibe.blog',
++ title: 'Publish a blog post',
++ why: 'Every member gets a blog on OpenVibe.Blog; a first post makes it yours.',
++ steps: [{ event_type: 'blog.post.published', count: 1, label: 'Publish a post on your blog' }],
++ reward: { badge: { id: 'first-post', name: 'First Post' }, coins: 15 },
++ repeatable: false,
++ },
++ {
++ id: 'share-a-deal',
++ site: 'openvibe.deals',
++ title: 'Share a deal',
++ why: 'A deal is only worth something when somebody posts it with its price and where it was seen.',
++ steps: [{ event_type: 'deals.offer.created', count: 1, label: 'Submit a deal' }],
++ reward: { badge: { id: 'deal-spotter', name: 'Deal Spotter' }, coins: 10 },
++ repeatable: false,
++ },
++ {
++ id: 'vote-on-deals',
++ site: 'openvibe.deals',
++ title: 'Vote on ten deals',
++ why: 'Votes decide what is hot on OpenVibe.Deals, one person, one vote.',
++ steps: [{ event_type: 'deals.vote.changed', count: 10, label: 'Vote a deal up or down' }],
++ reward: { badge: { id: 'deal-judge', name: 'Deal Judge' }, coins: 10 },
++ repeatable: false,
++ },
++ {
++ id: 'run-a-job',
++ site: 'openvibe.run',
++ title: 'Run a job on a node',
++ why: 'OpenVibe.Run sends a job to a machine that can do it, yours or the network\'s.',
++ steps: [{ event_type: 'run.job.succeeded', count: 1, label: 'Run a job that finishes' }],
++ reward: { badge: { id: 'job-runner', name: 'Job Runner' }, coins: 15 },
++ repeatable: false,
++ },
++ {
++ id: 'wear-an-item',
++ site: 'inventory.openvibe.network',
++ title: 'Wear an item',
++ why: 'Hats, name effects and particles you earn show on every OpenVibe site once you wear them.',
++ steps: [{ event_type: 'inventory.item.equipped', count: 1, label: 'Equip an item from your inventory' }],
++ reward: { badge: { id: 'dressed-up', name: 'Dressed Up' }, coins: 5 },
++ repeatable: false,
++ },
{
id: 'settle-in',
site: 'openvibe.network',
@@@ -373,6 +422,11 @@ const EVENTS = Object.freeze([
{ event_type: 'games.player.joined', site: 'openvibe.games', what: 'A character entered a world; the payload names its account\'s user subject (a guest is ignored).' },
{ event_type: 'tools.job.created', site: 'openvibe.tools', what: 'A tool job was accepted and queued; the payload names its owner (an anonymous session is ignored).' },
{ event_type: 'live.setup.task_completed', site: 'openvibe.live', what: 'A streamer finished a task on Live\'s go-live setup list for the first time; the payload names the streamer and the task, never what they made.' },
++ { event_type: 'blog.post.published', site: 'openvibe.blog', what: 'A post was published; the envelope actor is the person who published it (a scheduled or service publish is ignored).' },
++ { event_type: 'deals.offer.created', site: 'openvibe.deals', what: 'A deal was submitted; the envelope actor is the person who posted it (deals imported from a source are ignored).' },
++ { event_type: 'deals.vote.changed', site: 'openvibe.deals', what: 'A vote on a deal was cast or changed; the envelope actor is the voter.' },
++ { event_type: 'run.job.succeeded', site: 'openvibe.run', what: 'A job finished on a node; the payload names its requester (a service or app requester is ignored).' },
++ { event_type: 'inventory.item.equipped', site: 'inventory.openvibe.network', what: 'An item was equipped; the payload names its owner, never what else they own.' },
]);
// The events Quest actually counts: the consumer subscribes to exactly these, and a quest may not count an event
ddiff --git a/test/events-consumer.test.js b/test/events-consumer.test.js
iindex aa04cde..64c5652 100644
---- a/test/events-consumer.test.js
++++ b/test/events-consumer.test.js
@@@ -175,6 +175,27 @@ const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, c
assert.ok(quest.text.includes('<code>task</code> <code>emote</code>'), 'the quest page says which task it counts');
});
++ await check('the Blog, Deals, Run and Inventory quests: each event credits its person; a service never does', async () => {
++ const done = async () => (await completions(t, sam.subject)).map((c) => c.quest_id);
++ // A post published by a schedule or a service is nobody's.
++ await deliver(t, envelope('blog.post.published', { canonical_url: 'https://openvibe.blog/@sam/hi' }, { source: 'blog', actor: { type: 'service', id: 'blog' } }));
++ assert.ok(!(await done()).includes('publish-a-blog-post'));
++ await deliver(t, envelope('blog.post.published', { canonical_url: 'https://openvibe.blog/@sam/hi' }, { source: 'blog', actor: { type: 'user', id: sam.subject } }));
++ assert.ok((await done()).includes('publish-a-blog-post'));
++ await deliver(t, envelope('deals.offer.created', { offer_id: 'off_2' }, { source: 'deals', actor: { type: 'user', id: sam.subject } }));
++ assert.ok((await done()).includes('share-a-deal'));
++ for (let i = 0; i < 9; i++) await deliver(t, envelope('deals.vote.changed', { offer_id: `off_${i}`, value: 1 }, { source: 'deals', actor: { type: 'user', id: sam.subject } }));
++ assert.ok(!(await done()).includes('vote-on-deals'), 'nine votes are not ten');
++ await deliver(t, envelope('deals.vote.changed', { offer_id: 'off_9', value: -1 }, { source: 'deals', actor: { type: 'user', id: sam.subject } }));
++ assert.ok((await done()).includes('vote-on-deals'));
++ await deliver(t, envelope('run.job.succeeded', { job_id: 'job_1', requester: { type: 'app', id: 'app_x' } }, { source: 'run' }));
++ assert.ok(!(await done()).includes('run-a-job'), 'an app\'s job is nobody\'s');
++ await deliver(t, envelope('run.job.succeeded', { job_id: 'job_2', requester: { type: 'user', id: sam.subject } }, { source: 'run' }));
++ assert.ok((await done()).includes('run-a-job'));
++ await deliver(t, envelope('inventory.item.equipped', { instance_id: 'i', definition_id: 'd', kind: 'hat', slot: 'hat', owner: sam.subject }, { source: 'inventory' }));
++ assert.ok((await done()).includes('wear-an-item'));
++ });
++
await check('an event that names nobody creditable is ignored: no subject, a guest, a service, an unknown type', async () => {
const before = (await completions(t, sam.subject)).length;
const guest = 'gst_' + '0'.repeat(26);
@@@ -182,7 +203,7 @@ const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, c
chatMessage(null), // a private-ish/guest message: no user_subject
chatMessage(guest), // a guest id is not a usr_ subject
envelope('chat.room.message.created', { message_id: 3, room: { id: 'r', slug: 'g', name: 'G' }, text: 'x', created_at: iso(), user_subject: null }, { source: 'chat' }),
-- envelope('deals.vote.changed', { value: 1 }, { source: 'deals', actor: { type: 'user', id: sam.subject } }), // no person in the payload
++ envelope('deals.offer.created', { offer_id: 'off_1' }, { source: 'deals', actor: { type: 'service', id: 'deals' } }), // an imported deal: a service, not a person
envelope('quest.something.happened', { user_subject: sam.subject }), // not an event Quest counts
]) {
const r = await deliver(t, event);
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 972936c..10288bc 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -17,8 +17,9 @@ const BUDGETS = {
// Raised 2026-10-08 with the product: the home page now carries the quest catalog (12 quests, grouped by
// site, each with its reward) and the list of the events Quest counts. Measured 34.0 KB raw / 7.7 KB brotli.
// Raised 2026-10-12 with openvibe-shared 4.1.3: the head reserves the navbar's 52px (120 bytes). Measured 37.6 KB raw.
-- htmlRawKB: 41, // measured 37.6 (the home page: hero, features, the quests, the events, cta, JSON-LD)
-- htmlBrotliKB: 8.5, // 7.7
++ // Raised 2026-10-15: five more quests (Blog, Deals, Run, Inventory) and their events on the home page. Measured 43 KB raw.
++ htmlRawKB: 47, // measured 43 (the home page: hero, features, the quests, the events, cta, JSON-LD)
++ htmlBrotliKB: 9.8, // 9
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 264, // 239.8
jsBrotliKB: 62.5, // 56.5
ddiff --git a/test/quests-api.test.js b/test/quests-api.test.js
iindex f467df4..53c9f47 100644
---- a/test/quests-api.test.js
++++ b/test/quests-api.test.js
@@@ -27,7 +27,7 @@ const { render } = require('../server/render/html');
assert.match(r.headers.get('cache-control') || '', /no-store/);
const body = r.json();
assert.strictEqual(body.quests.length, catalog.all().length);
-- assert.ok(body.quests.length >= 8 && body.quests.length <= 32, `${body.quests.length} quests`);
++ assert.ok(body.quests.length >= 8 && body.quests.length <= 48, `${body.quests.length} quests`);
const setup = body.quests.filter((q) => q.series === 'live-setup');
assert.strictEqual(setup.length, 18, 'the Live setup series: a quest per task but go-live, and the whole setup');
assert.deepStrictEqual(body.quests.find((q) => q.id === 'live-setup-emote').steps, [{ event_type: 'live.setup.task_completed', count: 1, label: 'Upload a custom emote on OpenVibe.Live', match: { task: 'emote' } }]);