openvibers/openvibe.quest Commits
Quest: Bring a robot online (#54)
Changes
@@@ -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) | 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 |++| Catalog | [server/quests/catalog.js](server/quests/catalog.js) | 36 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 |@@@ -133,6 +133,7 @@ OpenVibe.Events delivers an event when a site really does something. Quest count| `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` |++| `bot.robot.online` | OpenVibe.Bot | `payload.owner`, the robot's owner (Bot names it since contracts 0.192.0) |A step can also name payload values to `match` (`{ task: 'emote' }`): the "Set up your channel" series is onequest per Live setup task, plus one that counts all 18 tasks. The home page shows a series as one card; its page is@@@ -146,7 +147,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, 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` |++| **OpenVibe sites** (Live, Community, Space, Chat, Wiki, Games, Tools, Network, Blog, Deals, Run, Inventory, Bot) | 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.@@@ -62,6 +62,7 @@ function personOf(event) {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_…)++ case 'bot.robot.online': // payload.owner (usr_…, contracts 0.192.0; absent from older events)return isUser(p.owner) ? p.owner : null;default:return null;@@@ -41,6 +41,7 @@ const SITES = Object.freeze({'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' },++ 'openvibe.bot': { name: 'OpenVibe.Bot', what: 'robots you drive from the browser' },});/**@@@ -204,6 +205,15 @@ const QUESTS = Object.freeze([reward: { badge: { id: 'dressed-up', name: 'Dressed Up' }, coins: 5 },repeatable: false,},++ {++ id: 'bring-a-robot-online',++ site: 'openvibe.bot',++ title: 'Bring a robot online',++ why: 'A robot is yours to drive from any browser once a device connects it: a Raspberry Pi, an ESP32, a phone.',++ steps: [{ event_type: 'bot.robot.online', count: 1, label: 'Connect a device to one of your robots' }],++ reward: { badge: { id: 'robot-pilot', name: 'Robot Pilot' }, coins: 20 },++ repeatable: false,++ },{id: 'settle-in',site: 'openvibe.network',@@@ -427,6 +437,7 @@ const EVENTS = Object.freeze([{ 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.' },++ { event_type: 'bot.robot.online', site: 'openvibe.bot', what: 'A device connected and serves a robot; the payload names the robot\'s owner (events from before Bot named it are ignored).' },]);// The events Quest actually counts: the consumer subscribes to exactly these, and a quest may not count an event@@@ -175,7 +175,7 @@ const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, cassert.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 () => {++ await check('the Blog, Deals, Run, Inventory and Bot 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' } }));@@@ -194,6 +194,10 @@ const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, cassert.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 deliver(t, envelope('bot.robot.online', { robot_id: 'rob_1', device_id: 'dev_1' }, { source: 'bot' }));++ assert.ok(!(await done()).includes('bring-a-robot-online'), 'an event that does not name the owner credits nobody');++ await deliver(t, envelope('bot.robot.online', { robot_id: 'rob_1', device_id: 'dev_1', owner: sam.subject }, { source: 'bot' }));++ assert.ok((await done()).includes('bring-a-robot-online'));});await check('an event that names nobody creditable is ignored: no subject, a guest, a service, an unknown type', async () => {
@@ -87,7 +87,7 @@ own** and produces no events; no `/api/v1` route is guarded by one ([server/http
@@ -87,7 +87,7 @@ own** and produces no events; no `/api/v1` route is guarded by one ([server/http
| Piece | Where | What it does |
| Piece | Where | What it does |
|---|---|---|
|---|---|---|
-| 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 |
+| Catalog | [server/quests/catalog.js](server/quests/catalog.js) | 36 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`) |
| 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 |
| 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 |
| 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 |
@@ -133,6 +133,7 @@ OpenVibe.Events delivers an event when a site really does something. Quest count
@@ -133,6 +133,7 @@ OpenVibe.Events delivers an event when a site really does something. Quest count
| `deals.offer.created` / `deals.vote.changed` | OpenVibe.Deals | the envelope actor: the submitter or the voter (an imported deal's actor is `svc:deals`) |
| `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) |
| `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` |
| `inventory.item.equipped` | OpenVibe.Inventory | `payload.owner` |
+| `bot.robot.online` | OpenVibe.Bot | `payload.owner`, the robot's owner (Bot names it since contracts 0.192.0) |
A step can also name payload values to `match` (`{ task: 'emote' }`): the "Set up your channel" series is one
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
quest per Live setup task, plus one that counts all 18 tasks. The home page shows a series as one card; its page is
@@ -146,7 +147,7 @@ coins are recorded once; a redelivery of the same event, or the whole request ag
@@ -146,7 +147,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.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-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.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, 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` |
+| **OpenVibe sites** (Live, Community, Space, Chat, Wiki, Games, Tools, Network, Blog, Deals, Run, Inventory, Bot) | 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.
Rewards are badges and OpenCoins. Nothing here is money, and nothing here is Vibes.
@@ -62,6 +62,7 @@ function personOf(event) {
@@ -62,6 +62,7 @@ function personOf(event) {
case 'run.job.succeeded': // payload.requester (identity.subject-ref@1: a user, service, app or mod)
case 'run.job.succeeded': // payload.requester (identity.subject-ref@1: a user, service, app or mod)
return refUser(p.requester);
return refUser(p.requester);
case 'inventory.item.equipped': // payload.owner (usr_…)
case 'inventory.item.equipped': // payload.owner (usr_…)
+ case 'bot.robot.online': // payload.owner (usr_…, contracts 0.192.0; absent from older events)
return isUser(p.owner) ? p.owner : null;
return isUser(p.owner) ? p.owner : null;
default:
default:
return null;
return null;
@@ -41,6 +41,7 @@ const SITES = Object.freeze({
@@ -41,6 +41,7 @@ const SITES = Object.freeze({
'openvibe.deals': { name: 'OpenVibe.Deals', what: 'deals the community shares and votes on' },
'openvibe.deals': { name: 'OpenVibe.Deals', what: 'deals the community shares and votes on' },
'openvibe.run': { name: 'OpenVibe.Run', what: 'jobs run on nodes' },
'openvibe.run': { name: 'OpenVibe.Run', what: 'jobs run on nodes' },
'inventory.openvibe.network': { name: 'OpenVibe.Inventory', what: 'the items you own across OpenVibe' },
'inventory.openvibe.network': { name: 'OpenVibe.Inventory', what: 'the items you own across OpenVibe' },
+ 'openvibe.bot': { name: 'OpenVibe.Bot', what: 'robots you drive from the browser' },
});
});
/**
/**
@@ -204,6 +205,15 @@ const QUESTS = Object.freeze([
@@ -204,6 +205,15 @@ const QUESTS = Object.freeze([
reward: { badge: { id: 'dressed-up', name: 'Dressed Up' }, coins: 5 },
reward: { badge: { id: 'dressed-up', name: 'Dressed Up' }, coins: 5 },
repeatable: false,
repeatable: false,
},
},
+ {
+ id: 'bring-a-robot-online',
+ site: 'openvibe.bot',
+ title: 'Bring a robot online',
+ why: 'A robot is yours to drive from any browser once a device connects it: a Raspberry Pi, an ESP32, a phone.',
+ steps: [{ event_type: 'bot.robot.online', count: 1, label: 'Connect a device to one of your robots' }],
+ reward: { badge: { id: 'robot-pilot', name: 'Robot Pilot' }, coins: 20 },
+ repeatable: false,
+ },
{
{
id: 'settle-in',
id: 'settle-in',
site: 'openvibe.network',
site: 'openvibe.network',
@@ -427,6 +437,7 @@ const EVENTS = Object.freeze([
@@ -427,6 +437,7 @@ const EVENTS = Object.freeze([
{ 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: '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: '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.' },
{ event_type: 'inventory.item.equipped', site: 'inventory.openvibe.network', what: 'An item was equipped; the payload names its owner, never what else they own.' },
+ { event_type: 'bot.robot.online', site: 'openvibe.bot', what: 'A device connected and serves a robot; the payload names the robot\'s owner (events from before Bot named it are ignored).' },
]);
]);
// The events Quest actually counts: the consumer subscribes to exactly these, and a quest may not count an event
// The events Quest actually counts: the consumer subscribes to exactly these, and a quest may not count an event
@@ -175,7 +175,7 @@ const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, c
@@ -175,7 +175,7 @@ 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');
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 () => {
+ await check('the Blog, Deals, Run, Inventory and Bot quests: each event credits its person; a service never does', async () => {
const done = async () => (await completions(t, sam.subject)).map((c) => c.quest_id);
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.
// 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' } }));
await deliver(t, envelope('blog.post.published', { canonical_url: 'https://openvibe.blog/@sam/hi' }, { source: 'blog', actor: { type: 'service', id: 'blog' } }));
@@ -194,6 +194,10 @@ const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, c
@@ -194,6 +194,10 @@ const progressOf = async (t, subject, questId) => (await rows(t, 'SELECT step, c
assert.ok((await done()).includes('run-a-job'));
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' }));
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'));
assert.ok((await done()).includes('wear-an-item'));
+ await deliver(t, envelope('bot.robot.online', { robot_id: 'rob_1', device_id: 'dev_1' }, { source: 'bot' }));
+ assert.ok(!(await done()).includes('bring-a-robot-online'), 'an event that does not name the owner credits nobody');
+ await deliver(t, envelope('bot.robot.online', { robot_id: 'rob_1', device_id: 'dev_1', owner: sam.subject }, { source: 'bot' }));
+ assert.ok((await done()).includes('bring-a-robot-online'));
});
});
await check('an event that names nobody creditable is ignored: no subject, a guest, a service, an unknown type', async () => {
await check('an event that names nobody creditable is ignored: no subject, a guest, a service, an unknown type', async () => {