openvibers/openvibe.quest Commits

Quest: quest pages in OpenVibe.Search (search-feed, quest.index_document.*) (#12)

OP OpenVibers committed 10 Oct 2026 · f1694a225a · 10 files, +208 −12

Plan T9 ("Search: real documents from every product") and T19 (Quest). Quest's quest pages go into OpenVibe.Search.

- `server/search-index.js` uses `openvibe-publishing/search-feed` (1.4.0): one `search.index-document@1` per quest (`/quests/<id>`), sent as `quest.index_document.upserted|deleted` (Contracts 0.128.0) through a new events outbox.
  - The document is the quest's title, why it's worth doing, its steps, the site and the badge (and item) it gives. Facets: site, badge, item.
  - **No OpenCoins:** they're off until the reward economics are decided (`QUEST_COINS`), so Search never promises them. The test checks that no document mentions coins.
- **When it runs:** quests are this service's own catalog, so a sweep a minute after boot and every six hours is enough. A deploy that changes the catalog sends a document, a new revision or a tombstone (`quest_index_revisions` is the sequencer).
- **Migration `0005_search_index.sql`** (expand): `event_outbox` + `quest_index_revisions`.
- **Delivery:** the relay publishes with Quest's token once `QUEST_EVENTS_URL`/`EVENTS_URL` and `OV_OAUTH_CLIENT_SECRET` are set; Network#120 grants it and Events#42 makes Quest a source.
- **Pins:** openvibe-contracts v0.128.0; openvibe-publishing v1.4.0 added.

Tests: `test/search-index.test.js`, 2 checks:
- every quest is sent once, valid against its contract, with its badge and item and no coins; the page answers and a second sweep sends nothing;
- a quest the catalog dropped becomes a tombstone, once.

Full suite 18/18.

Changes

ddiff --git a/README.md b/README.md
iindex 3206637..8c6d35a 100644
---- a/README.md
++++ b/README.md
@@@ -268,7 +268,8 @@ 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.127.0
++- openvibe-contracts: v0.128.0
++- openvibe-publishing: v1.4.0
- openvibe-sdk: v0.36.0
- openvibe-shared: v2.21.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex b438b69..a495044 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,13 +7,14 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": "4980",
"domain": "openvibe.quest",
-- "contracts": "openvibe-contracts v0.127.0",
++ "contracts": "openvibe-contracts v0.128.0",
"sdk": "openvibe-sdk v0.36.0",
"shared": "openvibe-shared v2.21.0",
"packages": [
-- "openvibe-contracts v0.127.0",
++ "openvibe-contracts v0.128.0",
"openvibe-sdk v0.36.0",
-- "openvibe-shared v2.21.0"
++ "openvibe-shared v2.21.0",
++ "openvibe-publishing v1.4.0"
],
"summary": "OpenVibe.Quest: one quest log across every OpenVibe site. OpenVibe.Events delivers the domain events the sites already emit to POST /internal/events (signed v2, once-only through the openvibe-sdk inbox), the person taken from the field each event's own schema defines, and a progress count, a completion and a badge move because of it. 12 first-party quests in code, grouped by the site whose events each counts. Rewards are badges and OpenCoins — credited through OpenVibe.Network's wallet, off by default (QUEST_COINS), with per-person and network-wide daily caps, failures recorded and retried. Public API and server-rendered pages, complete without JavaScript. Not deployed; OpenCoins are off until Network grants the two capabilities.",
"works": [
ddiff --git a/migrations/0005_search_index.sql b/migrations/0005_search_index.sql
nnew file mode 100644
iindex 0000000..6ab4547
---- /dev/null
++++ b/migrations/0005_search_index.sql
@@@ -0,0 +1,31 @@
++-- phase: expand
++-- OpenVibe.Quest: quest pages in OpenVibe.Search (server/search-index.js, openvibe-publishing/search-feed).
++-- event_outbox holds the quest.index_document.* events until the relay delivers them to OpenVibe.Events (written in
++-- the same transaction as the revision that produced them); quest_index_revisions is the index sequencer, the last
++-- revision and content hash Search was sent for each page, so an unchanged page is never sent twice.
++
++CREATE TABLE IF NOT EXISTS event_outbox (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ event_id text NOT NULL UNIQUE,
++ envelope jsonb NOT NULL,
++ traceparent text,
++ created_at bigint NOT NULL,
++ attempts integer NOT NULL DEFAULT 0,
++ next_attempt_at bigint NOT NULL DEFAULT 0,
++ sent_at bigint,
++ seq bigint,
++ rejected_at bigint,
++ last_error text
++);
++CREATE INDEX IF NOT EXISTS event_outbox_due ON event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;
++CREATE INDEX IF NOT EXISTS event_outbox_sent ON event_outbox (sent_at) WHERE sent_at IS NOT NULL;
++
++CREATE TABLE IF NOT EXISTS quest_index_revisions (
++ owner text COLLATE "C" NOT NULL,
++ type text COLLATE "C" NOT NULL,
++ id text COLLATE "C" NOT NULL,
++ revision integer NOT NULL,
++ hash text NOT NULL,
++ updated_at bigint NOT NULL,
++ PRIMARY KEY (owner, type, id)
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex d51f1cc..27eefaf 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,8 @@
"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.127.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.128.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.21.0",
"pg": "^8.23.0"
@@@ -871,9 +872,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.127.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",
-- "integrity": "sha512-ySC+Qh8I3OlN5MULCqv0vfcAGubZQzWM0Fw7EOpnOo76vVnrkwx+m33aTxDum2yQqPkU8Ot5UtMuMwm1Cc1k3g==",
++ "version": "0.128.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.128.0",
++ "integrity": "sha512-r/WKk3NhdqxEsVxqsVPjg+HTD2So1amRx0BY6EOHhvmwjLuL9j5XJrNDZAfqf/4/P2xZCBzTLrtUqlWxC2EA6Q==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -886,6 +887,24 @@
"node": ">=20"
}
},
++ "node_modules/openvibe-publishing": {
++ "version": "1.4.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
++ "integrity": "sha512-9vByyVqpvy2zVQI4PrJWk5QqCym/YQ/fXZDgrcloKGBqvukKrUn7fg4ZERiJHcgEq+rgDbxbU4rNflZhw62K/Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=20"
++ },
++ "peerDependencies": {
++ "openvibe-sdk": ">=0.15.0",
++ "openvibe-shared": ">=1.5.0"
++ },
++ "peerDependenciesMeta": {
++ "openvibe-shared": {
++ "optional": true
++ }
++ }
++ },
"node_modules/openvibe-sdk": {
"version": "0.36.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",
ddiff --git a/package.json b/package.json
iindex d0172bc..d5bb528 100644
---- a/package.json
++++ b/package.json
@@@ -2,7 +2,7 @@
"name": "openvibe-quest",
"version": "0.3.0",
"private": true,
-- "description": "OpenVibe.Quest — Quests across the whole network.",
++ "description": "OpenVibe.Quest \u2014 Quests across the whole network.",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
@@@ -22,7 +22,8 @@
"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.127.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.128.0",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.4.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.21.0",
"pg": "^8.23.0"
ddiff --git a/server/app.js b/server/app.js
iindex c255809..3973572 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -21,6 +21,7 @@ const rateLimit = require('express-rate-limit');
const contracts = require('openvibe-contracts');
const cache = require('openvibe-shared/cache-policy');
++const { createSearchIndex } = require('./search-index');
const configLib = require('./config');
const { openStore } = require('./db');
const { createKeyStore } = require('./auth/keys');
@@@ -66,7 +67,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: Quest cannot answer account events'); });
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 };
++ // The quests in OpenVibe.Search (./search-index.js), swept on its own timer once server/index.js starts it.
++ const searchIndex = opts.searchIndex || createSearchIndex({ config, s, log });
++ const ctx = { config, s, keys, sso, principal, coins, items, eventsConsumer, accountData, accountSend, searchIndex, log };
const app = express();
app.disable('x-powered-by');
ddiff --git a/server/index.js b/server/index.js
iindex 5ae52fc..d5e6cc9 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -16,7 +16,7 @@ const { gracefulStop } = require('openvibe-sdk/service');
function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
return gracefulStop({
name: 'OpenVibe.Quest', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
-- close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.keys.client.stop(), () => ctx.s.close(), ...extra],
++ close: [() => { for (const t of timers) clearInterval(t); }, () => ctx.keys.client.stop(), () => ctx.searchIndex.stop(), () => ctx.s.close(), ...extra],
});
}
@@@ -29,6 +29,7 @@ async function start() {
});
server.keepAliveTimeout = 65_000;
ctx.keys.client.start();
++ ctx.searchIndex.start();
// Subscribe to the events the catalog counts at OpenVibe.Events (idempotent; off without QUEST_EVENTS_URL
// and QUEST_EVENTS_SECRET) and come back to OpenCoins a failed credit owes (off unless QUEST_COINS=on).
ddiff --git a/server/search-index.js b/server/search-index.js
nnew file mode 100644
iindex 0000000..cd7ec74
---- /dev/null
++++ b/server/search-index.js
@@@ -0,0 +1,85 @@
++'use strict';
++
++/**
++ * Quest's quest pages in OpenVibe.Search (openvibe-publishing/search-feed): one search.index-document@1 per quest
++ * (/quests/<id>), sent as quest.index_document.upserted|deleted through this service's events outbox. The document is
++ * what the page says: the quest's title, why it is worth doing, its steps, the site it happens on and the badge (and
++ * item) it gives. Not the OpenCoins: they are off until the reward economics are decided (QUEST_COINS), and Search
++ * should never promise them.
++ *
++ * Quests are this service's own catalog (server/quests/catalog.js), so a sweep a minute after boot and every six
++ * hours is enough: a deploy that adds, rewords or drops a quest sends a document, a new revision or a tombstone
++ * (quest_index_revisions is the sequencer), and a quiet boot sends nothing.
++ */
++const { createServiceOutbox } = require('openvibe-sdk/events');
++const { createSearchFeed } = require('openvibe-publishing/search-feed');
++const catalog = require('./quests/catalog');
++
++const EVENT_TYPES = ['quest.index_document.upserted', 'quest.index_document.deleted'];
++const START_DELAY_MS = 60_000;
++const INTERVAL_MS = 6 * 3_600_000;
++
++/** One quest → what its page says, as search-feed's document description. */
++function describe(quest) {
++ const site = catalog.SITES[quest.site] || { name: quest.site };
++ const steps = quest.steps.map((st) => (st.count > 1 ? `${st.label} (${st.count} times)` : st.label));
++ const reward = [`the ${quest.reward.badge.name} badge`, quest.reward.item ? `the ${quest.reward.item.name} ${String(quest.reward.item.kind).toLowerCase()} for your inventory` : '']
++ .filter(Boolean).join(' and ');
++ return {
++ title: quest.title,
++ summary: `${quest.why} On ${site.name}; earns ${reward}.`,
++ body: [quest.title, quest.why, `Where: ${site.name}`, `Steps: ${steps.join('; ')}`, `Reward: ${reward}`].join('\n'),
++ facets: { site: quest.site, badge: quest.reward.badge.id, item: Boolean(quest.reward.item) },
++ authorship: { mode: 'human' },
++ };
++}
++
++function createSearchIndex({ config, s, log = console, outbox = null, quests = catalog.all() }) {
++ const out = outbox || createServiceOutbox({
++ db: s.db, source: 'quest', eventsUrl: config.events.url || null, networkInternalUrl: config.networkInternalUrl,
++ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, log, eventTypes: EVENT_TYPES,
++ });
++ const ordered = [...quests].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
++ const ids = new Set(ordered.map((q) => q.id));
++ const feed = createSearchFeed({
++ owner: 'quest', db: s.db, outbox: out, baseUrl: config.baseUrl, now: s.now, log,
++ types: {
++ quest: {
++ page: (q) => `/quests/${q.id}`,
++ document: describe,
++ rows: async (after, limit) => ordered.filter((q) => q.id > after).slice(0, limit),
++ exists: async (list) => list.filter((id) => ids.has(id)),
++ },
++ },
++ });
++ const timers = [];
++
++ async function sweep() {
++ const res = await feed.sweep();
++ const q = res.quest;
++ if (q.sent || q.removed || q.failed) log.log(`[Search] quests: ${q.sent} sent, ${q.removed} removed, ${q.failed} failed of ${q.seen}`);
++ if (out.kick) out.kick().catch(() => {});
++ return res;
++ }
++ const quietly = () => { sweep().catch((err) => log.warn(`[Search] sweep failed: ${(err && err.message) || err}`)); };
++
++ function start() {
++ if (timers.length) return false;
++ out.start();
++ const kick = setTimeout(quietly, START_DELAY_MS);
++ if (kick.unref) kick.unref();
++ const tick = setInterval(quietly, INTERVAL_MS);
++ if (tick.unref) tick.unref();
++ timers.push(kick, tick);
++ return true;
++ }
++
++ function stop() {
++ for (const t of timers.splice(0)) { clearTimeout(t); clearInterval(t); }
++ out.stop();
++ }
++
++ return { feed, outbox: out, describe, sweep, start, stop, status: () => out.status() };
++}
++
++module.exports = { createSearchIndex, describe, EVENT_TYPES };
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 45edf26..90dd75e 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -69,6 +69,7 @@ async function boot(opts = {}) {
base, network, config, ctx: built.ctx, get, signIn, dbDump,
logs: () => captured.join('\n'),
async close() {
++ built.ctx.searchIndex.stop();
await new Promise((r) => server.close(r));
built.ctx.keys.client.stop();
await testdb.close();
ddiff --git a/test/search-index.test.js b/test/search-index.test.js
nnew file mode 100644
iindex 0000000..b067667
---- /dev/null
++++ b/test/search-index.test.js
@@@ -0,0 +1,53 @@
++'use strict';
++/**
++ * Quests in OpenVibe.Search (server/search-index.js): a sweep puts every quest in the outbox as one
++ * quest.index_document.upserted, exactly the contract, with its /quests/<id> page (which answers) and its badge, and
++ * never a promise of OpenCoins; a second sweep sends nothing; a quest the catalog dropped becomes a tombstone.
++ */
++const assert = require('assert');
++const contracts = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++const catalog = require('../server/quests/catalog');
++const { createSearchIndex } = require('../server/search-index');
++
++(async () => {
++ const t = await boot();
++ const { s, searchIndex } = t.ctx;
++ const outbox = async () => (await s.db.many('SELECT envelope FROM event_outbox ORDER BY id')).map((r) => r.envelope);
++ const valid = (env) => {
++ assert.ok(contracts.validate('events.event-envelope@1', env).valid, 'envelope');
++ const p = contracts.validate(env.event_type, env.payload);
++ assert.ok(p.valid, `${env.event_type}: ${JSON.stringify(p.errors)}`);
++ assert.strictEqual(env.source, 'quest');
++ return env;
++ };
++
++ try {
++ await check('a sweep sends every quest once, as the contract says, and the page it names answers', async () => {
++ const quests = catalog.all();
++ const res = await searchIndex.sweep();
++ assert.deepStrictEqual(res.quest, { seen: quests.length, sent: quests.length, removed: 0, failed: 0 });
++ const envs = (await outbox()).map(valid);
++ const doc = envs.find((e) => e.payload.id === 'go-live').payload;
++ assert.strictEqual(doc.title, 'Go live once');
++ assert.strictEqual(doc.canonical_url, 'https://openvibe.quest/quests/go-live');
++ assert.match(doc.summary, /First Broadcast badge/);
++ assert.match(doc.summary, /Basic Cap hat/);
++ assert.deepStrictEqual(doc.facets, { site: 'openvibe.live', badge: 'first-broadcast', item: true });
++ assert.ok(!/coin/i.test(JSON.stringify(envs)), 'no document promises OpenCoins');
++ assert.strictEqual((await t.get('/quests/go-live')).status, 200);
++ assert.strictEqual((await searchIndex.sweep()).quest.sent, 0, 'a second sweep sends nothing');
++ });
++
++ await check('a quest the catalog dropped becomes a tombstone, once', async () => {
++ const fewer = catalog.all().filter((q) => q.id !== 'go-live');
++ const other = createSearchIndex({ config: t.config, s, outbox: searchIndex.outbox, quests: fewer, log: { log() {}, warn() {} } });
++ assert.strictEqual((await other.sweep()).quest.removed, 1);
++ const env = valid((await outbox()).pop());
++ assert.deepStrictEqual([env.event_type, env.payload], ['quest.index_document.deleted', { type: 'quest', id: 'go-live', revision: 2 }]);
++ assert.strictEqual((await other.sweep()).quest.removed, 0);
++ });
++ } finally {
++ await done(t);
++ }
++})();