openvibers/openvibe.quest Commits
Quest: quest pages in OpenVibe.Search (search-feed, quest.index_document.*) (#12)
Changes
@@@ -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 -->@@@ -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": [nnew file mode 100644@@@ -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)++);@@@ -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",@@@ -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"@@@ -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');@@@ -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).nnew file mode 100644@@@ -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 };@@@ -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();nnew file mode 100644@@@ -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);++ }++})();
@@ -268,7 +268,8 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.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).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.127.0
+- openvibe-contracts: v0.128.0
+- openvibe-publishing: v1.4.0
- openvibe-sdk: v0.36.0
- openvibe-sdk: v0.36.0
- openvibe-shared: v2.21.0
- openvibe-shared: v2.21.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,13 +7,14 @@
@@ -7,13 +7,14 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": "4980",
"port": "4980",
"domain": "openvibe.quest",
"domain": "openvibe.quest",
- "contracts": "openvibe-contracts v0.127.0",
+ "contracts": "openvibe-contracts v0.128.0",
"sdk": "openvibe-sdk v0.36.0",
"sdk": "openvibe-sdk v0.36.0",
"shared": "openvibe-shared v2.21.0",
"shared": "openvibe-shared v2.21.0",
"packages": [
"packages": [
- "openvibe-contracts v0.127.0",
+ "openvibe-contracts v0.128.0",
"openvibe-sdk v0.36.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.",
"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": [
"works": [
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -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)
+);
@@ -15,7 +15,8 @@
@@ -15,7 +15,8 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.21.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -871,9 +872,9 @@
@@ -871,9 +872,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -886,6 +887,24 @@
@@ -886,6 +887,24 @@
"node": ">=20"
"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": {
"node_modules/openvibe-sdk": {
"version": "0.36.0",
"version": "0.36.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.36.0",
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"name": "openvibe-quest",
"name": "openvibe-quest",
"version": "0.3.0",
"version": "0.3.0",
"private": true,
"private": true,
- "description": "OpenVibe.Quest — Quests across the whole network.",
+ "description": "OpenVibe.Quest \u2014 Quests across the whole network.",
"main": "server/index.js",
"main": "server/index.js",
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
@@ -22,7 +22,8 @@
@@ -22,7 +22,8 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.21.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -21,6 +21,7 @@ const rateLimit = require('express-rate-limit');
@@ -21,6 +21,7 @@ const rateLimit = require('express-rate-limit');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
+const { createSearchIndex } = require('./search-index');
const configLib = require('./config');
const configLib = require('./config');
const { openStore } = require('./db');
const { openStore } = require('./db');
const { createKeyStore } = require('./auth/keys');
const { createKeyStore } = require('./auth/keys');
@@ -66,7 +67,9 @@ async function createApp(opts = {}) {
@@ -66,7 +67,9 @@ async function createApp(opts = {}) {
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Quest cannot answer account events'); });
: 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 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();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
@@ -16,7 +16,7 @@ const { gracefulStop } = require('openvibe-sdk/service');
@@ -16,7 +16,7 @@ const { gracefulStop } = require('openvibe-sdk/service');
function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
return gracefulStop({
return gracefulStop({
name: 'OpenVibe.Quest', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
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() {
@@ -29,6 +29,7 @@ async function start() {
});
});
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
ctx.keys.client.start();
ctx.keys.client.start();
+ ctx.searchIndex.start();
// Subscribe to the events the catalog counts at OpenVibe.Events (idempotent; off without QUEST_EVENTS_URL
// 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).
// and QUEST_EVENTS_SECRET) and come back to OpenCoins a failed credit owes (off unless QUEST_COINS=on).
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -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 };
@@ -69,6 +69,7 @@ async function boot(opts = {}) {
@@ -69,6 +69,7 @@ async function boot(opts = {}) {
base, network, config, ctx: built.ctx, get, signIn, dbDump,
base, network, config, ctx: built.ctx, get, signIn, dbDump,
logs: () => captured.join('\n'),
logs: () => captured.join('\n'),
async close() {
async close() {
+ built.ctx.searchIndex.stop();
await new Promise((r) => server.close(r));
await new Promise((r) => server.close(r));
built.ctx.keys.client.stop();
built.ctx.keys.client.stop();
await testdb.close();
await testdb.close();
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -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);
+ }
+})();