openvibers/openvibe.fun Commits
Fun quests and plans reach OpenVibe.Search (search-relay) (#59)
Changes
@@@ -35,6 +35,8 @@ OV_OAUTH_CLIENT_SECRET=# needs the events.subscription.manage capability for svc:fun.FUN_EVENTS_SECRET=FUN_EVENTS_URL=++# OpenVibe.Search's owner API: the queued Search documents (fun_outbox) are delivered there. Unset: they wait.++#FUN_SEARCH_URL=http://127.0.0.1:4710#FUN_EVENTS_ENDPOINT=http://127.0.0.1:5140/internal/events#FUN_EVENTS_INTERVAL_MS=5000@@@ -50,8 +50,9 @@ external write is a plan added to *your own* Today.- **Identity and roles** — OpenVibe.Network. Fun is OAuth client `fun` (PKCE S256) and verifies session tokensagainst the Network JWKS; staff means Network role `global_mod` or above.- **OpenStreetMap and Ticketmaster** — external public sources, cached and credited; never a paid provider.--- **Search** — OpenVibe.Search receives `search.index-document@1` documents from `fun_outbox` when its writer is-- configured; the relay needs `search.document.write` and is not in v1.++- **Search** — OpenVibe.Search receives `search.index-document@1` documents from `fun_outbox`:++ `openvibe-publishing/search-relay` delivers them to its owner API with Fun's `search.document.write` grant++ (`FUN_SEARCH_URL`; unset, they wait).- **Events, the host and the database** — OpenVibe.Events, OpenVibe.Host.## Capabilities and grants@@@ -70,8 +71,8 @@ cookie or a Network user token; a signed-in write from the cookie must come fromto credit OpenCoins through OpenVibe.Network after a quest is verified. Off by default; the badge is still paid.**Not needed in v1:** no Live, Chat, Today, Quest, Media or AI service token. The Today write uses the signed-in--person's own Network token, and the guide reads public OSM/Ticketmaster endpoints. A future Search relay needs--`search.document.write`; a future streamer-ownership proof could use a Live capability, but v1 checks the Network++person's own Network token, and the guide reads public OSM/Ticketmaster endpoints. The Search relay uses++`search.document.write` (held); a future streamer-ownership proof could use a Live capability, but v1 checks the Networkusername against the public Live channel.## API@@@ -27,7 +27,7 @@"A deterministic local guide from Nominatim/Overpass with a contact User-Agent, a per-host pacer, a seven-day cache, stale fallback and ODbL attribution","Ticketmaster Discovery events only when TICKETMASTER_API_KEY is set; the key is never cached, returned or logged","Group plans with options, one vote per person, a tie refused, an owner/staff decision and a winner added to the viewer's own Today via the person's token",-- "Search Fabric documents (owner fun; types quest and plan) in fun_outbox, with valid tombstones on hide/cancel",++ "Search Fabric documents (owner fun; types quest and plan) in fun_outbox, with valid tombstones on hide/cancel, delivered to Search's owner API by openvibe-publishing/search-relay","Sign-in with OpenVibe.Network (PKCE S256, fun_at/fun_rt/fun_oauth), staff role global_mod+","Account export and deletion: personal rows erased; public quests/boards and shared plans anonymized, never silently kept with a name","Server-rendered pages with stable fu- ids/classes, a no-JS OBS overlay, discovery artifacts and deploy files"@@@ -36,7 +36,6 @@"An AI chat or model-generated guide: v1 is deterministic and adds no AI capability","Ticketmaster events without a key: v1 is OSM-only by default","OpenCoins without the FUN_COINS=on setting and the identity.subject.resolve + network.coins.credit grants: the badge is still paid",-- "The Search document relay: documents wait in fun_outbox until Network grants fun search.document.write","A service-token proof of Live channel ownership: v1 checks the public channel and matching Network username","Fan-out of a decided group plan to every member's Today: each viewer adds it to their own with their own token","Parsing OSM opening_hours into open-now; the guide shows the raw OSM value",@@@ -16,6 +16,7 @@"helmet": "^8.1.0","iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.103.0","pg": "^8.23.0"@@@ -1165,6 +1166,24 @@"node": ">=20"}},++ "node_modules/openvibe-publishing": {++ "version": "1.5.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",++ "integrity": "sha512-8cKVhJvpbDgWuGR+Dh1mpgqfWPJoC7EIcw7eTRc5/2ueDc8caCA7XKdcpAHNZYMdukIAL8o7FvxE8QV5/1uSLw==",++ "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.81.0","resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",@@@ -23,6 +23,7 @@"helmet": "^8.1.0","iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.103.0","pg": "^8.23.0"@@@ -35,6 +35,7 @@ const { raw } = require('./render/html');const accountDataLib = require('./identity/account-data');const { createStore } = require('./store');const { createSearchIndex } = require('./search');++const { createSearchRelay } = require('openvibe-publishing/search-relay');const { createUpstreams } = require('./upstreams');const { createGuide } = require('./guide');const { createUpstreams: createGuideUpstreams } = require('./guide/upstream');@@@ -61,7 +62,16 @@ async function createApp(opts = {}) {const sso = createSso({ config, keys, fetchImpl, now: s.now, log });const principal = createPrincipal({ config, keys });-- const search = createSearchIndex({ config, s });++ // The queued Search documents (fun_outbox) go to OpenVibe.Search's owner API with this service's token; off++ // without FUN_SEARCH_URL or the client secret. Started by server/index.js, never under FUN_DRILL.++ const searchRelay = createSearchRelay({++ db: s.db, table: 'fun_outbox', owner: 'fun', searchUrl: config.search.url, fetchImpl, now: s.now, log,++ auth: config.search.url && config.oauth.clientSecret ? contracts.serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.search', scope: 'search.document.write', fetchImpl,++ }) : null,++ });++ const search = createSearchIndex({ config, s, onQueued: () => searchRelay.kick() });const store = createStore(s, { search, config });const upstreams = createUpstreams({ config, fetchImpl, now: s.now, log });const guideUpstream = createGuideUpstreams({ config, s, fetchImpl, now: s.now, log });@@@ -77,7 +87,7 @@ async function createApp(opts = {}) {: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Fun cannot answer account events'); });const ctx = {-- config, s, keys, sso, principal, search, store, upstreams, guide, guideUpstream, coins, chatSync,++ config, s, keys, sso, principal, search, searchRelay, store, upstreams, guide, guideUpstream, coins, chatSync,accountData, accountSend, log,};@@@ -62,6 +62,10 @@ function load(env = process.env) {scope: env.FUN_EVENTS_SCOPE || 'events.subscription.manage',},++ // OpenVibe.Search's owner API: the documents queued in fun_outbox are delivered there (server/app.js,++ // openvibe-publishing/search-relay) with this service's token (search.document.write). Unset: they wait.++ search: { url: trim(env.FUN_SEARCH_URL || '') },++// The public APIs Fun reads: Live's channel status, Chat's public history (chat votes are read, never// posted), and Today's public event API (called with the viewer's own Network token, never a service one).upstreams: {@@@ -21,6 +21,7 @@ function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }() => ctx.keys.client.stop(),() => ctx.chatSync.stop(),() => ctx.coins.stop(),++ () => ctx.searchRelay.stop(),async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); },() => ctx.s.close(),...extra,@@@ -48,6 +49,8 @@ async function start() {// Jobs. Chat votes are read-only; both refuse to start under FUN_DRILL, where tests call runOnce/retryPending.ctx.chatSync.start();ctx.coins.start();++ // Queued Search documents → OpenVibe.Search (a tick a few seconds after boot, then every minute).++ if (!config.drill) ctx.searchRelay.start();// The two account topics at OpenVibe.Events: idempotent; off without the URL, the delivery secret and the client// secret. The consumer itself is mounted in server/app.js.@@@ -4,8 +4,9 @@* OpenVibe.Fun's Search Fabric feed. A public IRL quest is one `search.index-document@1` (owner `fun`, type `quest`,* page `/q/<slug>`); a public group plan is another (type `plan`, page `/plans/<slug>`). They are queued in* `fun_outbox` in the same transaction as the change; hiding or cancelling one queues a tombstone, which wins over-- * any older revision. The relay is deliberately not part of v1: rows wait until OpenVibe.Search's document writer-- * is configured for `fun` (it needs the search.document.write grant).++ * any older revision. openvibe-publishing/search-relay (server/app.js) delivers them to Search's owner API with++ * this service's token (search.document.write), a few seconds after each change and every minute; without++ * FUN_SEARCH_URL they wait.** A quest/plan body is the public text the person wrote plus the public facts around it. No voter, subject, chat* author or token is ever part of a document.@@@ -107,7 +108,7 @@ function planDocument(p, options = [], { baseUrl = 'https://openvibe.fun' } = {}};}--function createSearchIndex({ config, s, baseUrl = config.baseUrl }) {++function createSearchIndex({ config, s, baseUrl = config.baseUrl, onQueued = null }) {const nowIso = () => new Date(s.now()).toISOString();const base = String(baseUrl).replace(/\/+$/, '');@@@ -119,6 +120,7 @@ function createSearchIndex({ config, s, baseUrl = config.baseUrl }) {VALUES ($1, $2, $3::jsonb, $4::timestamptz, $4::timestamptz)`,[eventId, topic, JSON.stringify(payload), nowIso()],);++ if (onQueued) onQueued();return eventId;}@@@ -398,7 +398,7 @@ ${ep('POST', '/api/v1/plans/:slug/options|vote|decide', 'Add an option, cast one${ep('POST', '/api/v1/plans/:slug/today', 'Adds the winner to the caller\'s own OpenVibe.Today with their token and an idempotency key.')}${ep('POST', '/api/v1/plans/:slug/hide', 'Staff.')}</dl></section>--<section id="fu-search"><h2>Search</h2><p class="fu-fine">Each public quest is one <code>search.index-document@1</code> (owner <code>fun</code>, type <code>quest</code>, <code>/q/<slug></code>); each public plan is type <code>plan</code> (<code>/plans/<slug></code>). They wait in <code>fun_outbox</code>; hiding or cancelling queues a tombstone. The relay needs the <code>search.document.write</code> grant and is not in v1.</p></section>++<section id="fu-search"><h2>Search</h2><p class="fu-fine">Each public quest is one <code>search.index-document@1</code> (owner <code>fun</code>, type <code>quest</code>, <code>/q/<slug></code>); each public plan is type <code>plan</code> (<code>/plans/<slug></code>). They are queued in <code>fun_outbox</code> and delivered to OpenVibe.Search within a minute; hiding or cancelling queues a tombstone.</p></section></div>`;}nnew file mode 100644@@@ -0,0 +1,60 @@++'use strict';++/**++ * The queued Search documents reach OpenVibe.Search (server/app.js, openvibe-publishing/search-relay): with++ * FUN_SEARCH_URL set, accepting a quest PUTs its document to Search's owner API with a service token for++ * openvibe.search, and the row leaves the queue. Without the URL the relay is off and the rows wait.++ */++const assert = require('assert');++const http = require('http');++const { boot, check, done } = require('./helpers/boot');++++function fakeSearch() {++ const calls = [];++ const server = http.createServer((req, res) => {++ let body = '';++ req.on('data', (c) => { body += c; });++ req.on('end', () => {++ calls.push({ method: req.method, url: req.url, auth: req.headers.authorization || '', body: body ? JSON.parse(body) : null });++ res.writeHead(200, { 'content-type': 'application/json' });++ res.end(JSON.stringify({ outcome: 'applied' }));++ });++ });++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) })));++}++++(async () => {++ const search = await fakeSearch();++ const t = await boot({ env: { FUN_SEARCH_URL: search.url } });++ try {++ const ada = t.network.addUser('ada');++ const kim = t.network.addUser('kim');++++ await check('an accepted quest is PUT to Search with a service token, and the row leaves the queue', async () => {++ assert.strictEqual(t.ctx.searchRelay.enabled, true);++ const b = await t.get('/api/v1/quests/boards', { as: ada, json: { live_username: 'ada', title: 'Relay board' } });++ const quest = (await t.get(`/api/v1/quests/boards/${b.json().id}/suggest`, { as: kim, json: { title: 'Find the oldest tree', brief: 'Walk to the park.' } })).json();++ assert.strictEqual((await t.get(`/api/v1/quests/${quest.slug}/accept`, { as: ada, json: {} })).status, 200);++ const out = await t.ctx.searchRelay.drain();++ assert.ok(out.sent >= 1, JSON.stringify(out));++ const put = search.calls.find((c) => c.method === 'PUT' && c.url === `/api/v1/documents/fun/quest/${quest.id}`);++ assert.ok(put, JSON.stringify(search.calls.map((c) => `${c.method} ${c.url}`)));++ assert.match(put.auth, /^Bearer \S+/);++ assert.strictEqual(put.body.canonical_url, `https://openvibe.fun/q/${quest.slug}`);++ assert.strictEqual((await t.ctx.searchRelay.status()).pending, 0);++ });++ } finally {++ await t.close();++ await search.close();++ }++++ const off = await boot();++ try {++ await check('without FUN_SEARCH_URL the relay is off', async () => {++ assert.strictEqual(off.ctx.searchRelay.enabled, false);++ assert.strictEqual(await off.ctx.searchRelay.drain(), null);++ });++ } finally {++ await off.close();++ }++ done();++})();
@@ -35,6 +35,8 @@ OV_OAUTH_CLIENT_SECRET=
@@ -35,6 +35,8 @@ OV_OAUTH_CLIENT_SECRET=
# needs the events.subscription.manage capability for svc:fun.
# needs the events.subscription.manage capability for svc:fun.
FUN_EVENTS_SECRET=
FUN_EVENTS_SECRET=
FUN_EVENTS_URL=
FUN_EVENTS_URL=
+# OpenVibe.Search's owner API: the queued Search documents (fun_outbox) are delivered there. Unset: they wait.
+#FUN_SEARCH_URL=http://127.0.0.1:4710
#FUN_EVENTS_ENDPOINT=http://127.0.0.1:5140/internal/events
#FUN_EVENTS_ENDPOINT=http://127.0.0.1:5140/internal/events
#FUN_EVENTS_INTERVAL_MS=5000
#FUN_EVENTS_INTERVAL_MS=5000
@@ -50,8 +50,9 @@ external write is a plan added to *your own* Today.
@@ -50,8 +50,9 @@ external write is a plan added to *your own* Today.
- **Identity and roles** — OpenVibe.Network. Fun is OAuth client `fun` (PKCE S256) and verifies session tokens
- **Identity and roles** — OpenVibe.Network. Fun is OAuth client `fun` (PKCE S256) and verifies session tokens
against the Network JWKS; staff means Network role `global_mod` or above.
against the Network JWKS; staff means Network role `global_mod` or above.
- **OpenStreetMap and Ticketmaster** — external public sources, cached and credited; never a paid provider.
- **OpenStreetMap and Ticketmaster** — external public sources, cached and credited; never a paid provider.
-- **Search** — OpenVibe.Search receives `search.index-document@1` documents from `fun_outbox` when its writer is
- configured; the relay needs `search.document.write` and is not in v1.
+- **Search** — OpenVibe.Search receives `search.index-document@1` documents from `fun_outbox`:
+ `openvibe-publishing/search-relay` delivers them to its owner API with Fun's `search.document.write` grant
+ (`FUN_SEARCH_URL`; unset, they wait).
- **Events, the host and the database** — OpenVibe.Events, OpenVibe.Host.
- **Events, the host and the database** — OpenVibe.Events, OpenVibe.Host.
## Capabilities and grants
## Capabilities and grants
@@ -70,8 +71,8 @@ cookie or a Network user token; a signed-in write from the cookie must come from
@@ -70,8 +71,8 @@ cookie or a Network user token; a signed-in write from the cookie must come from
to credit OpenCoins through OpenVibe.Network after a quest is verified. Off by default; the badge is still paid.
to credit OpenCoins through OpenVibe.Network after a quest is verified. Off by default; the badge is still paid.
**Not needed in v1:** no Live, Chat, Today, Quest, Media or AI service token. The Today write uses the signed-in
**Not needed in v1:** no Live, Chat, Today, Quest, Media or AI service token. The Today write uses the signed-in
-person's own Network token, and the guide reads public OSM/Ticketmaster endpoints. A future Search relay needs
-`search.document.write`; a future streamer-ownership proof could use a Live capability, but v1 checks the Network
+person's own Network token, and the guide reads public OSM/Ticketmaster endpoints. The Search relay uses
+`search.document.write` (held); a future streamer-ownership proof could use a Live capability, but v1 checks the Network
username against the public Live channel.
username against the public Live channel.
## API
## API
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"A deterministic local guide from Nominatim/Overpass with a contact User-Agent, a per-host pacer, a seven-day cache, stale fallback and ODbL attribution",
"A deterministic local guide from Nominatim/Overpass with a contact User-Agent, a per-host pacer, a seven-day cache, stale fallback and ODbL attribution",
"Ticketmaster Discovery events only when TICKETMASTER_API_KEY is set; the key is never cached, returned or logged",
"Ticketmaster Discovery events only when TICKETMASTER_API_KEY is set; the key is never cached, returned or logged",
"Group plans with options, one vote per person, a tie refused, an owner/staff decision and a winner added to the viewer's own Today via the person's token",
"Group plans with options, one vote per person, a tie refused, an owner/staff decision and a winner added to the viewer's own Today via the person's token",
- "Search Fabric documents (owner fun; types quest and plan) in fun_outbox, with valid tombstones on hide/cancel",
+ "Search Fabric documents (owner fun; types quest and plan) in fun_outbox, with valid tombstones on hide/cancel, delivered to Search's owner API by openvibe-publishing/search-relay",
"Sign-in with OpenVibe.Network (PKCE S256, fun_at/fun_rt/fun_oauth), staff role global_mod+",
"Sign-in with OpenVibe.Network (PKCE S256, fun_at/fun_rt/fun_oauth), staff role global_mod+",
"Account export and deletion: personal rows erased; public quests/boards and shared plans anonymized, never silently kept with a name",
"Account export and deletion: personal rows erased; public quests/boards and shared plans anonymized, never silently kept with a name",
"Server-rendered pages with stable fu- ids/classes, a no-JS OBS overlay, discovery artifacts and deploy files"
"Server-rendered pages with stable fu- ids/classes, a no-JS OBS overlay, discovery artifacts and deploy files"
@@ -36,7 +36,6 @@
@@ -36,7 +36,6 @@
"An AI chat or model-generated guide: v1 is deterministic and adds no AI capability",
"An AI chat or model-generated guide: v1 is deterministic and adds no AI capability",
"Ticketmaster events without a key: v1 is OSM-only by default",
"Ticketmaster events without a key: v1 is OSM-only by default",
"OpenCoins without the FUN_COINS=on setting and the identity.subject.resolve + network.coins.credit grants: the badge is still paid",
"OpenCoins without the FUN_COINS=on setting and the identity.subject.resolve + network.coins.credit grants: the badge is still paid",
- "The Search document relay: documents wait in fun_outbox until Network grants fun search.document.write",
"A service-token proof of Live channel ownership: v1 checks the public channel and matching Network username",
"A service-token proof of Live channel ownership: v1 checks the public channel and matching Network username",
"Fan-out of a decided group plan to every member's Today: each viewer adds it to their own with their own token",
"Fan-out of a decided group plan to every member's Today: each viewer adds it to their own with their own token",
"Parsing OSM opening_hours into open-now; the guide shows the raw OSM value",
"Parsing OSM opening_hours into open-now; the guide shows the raw OSM value",
@@ -16,6 +16,7 @@
@@ -16,6 +16,7 @@
"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.236.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",
+ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.103.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.103.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1165,6 +1166,24 @@
@@ -1165,6 +1166,24 @@
"node": ">=20"
"node": ">=20"
}
}
},
},
+ "node_modules/openvibe-publishing": {
+ "version": "1.5.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
+ "integrity": "sha512-8cKVhJvpbDgWuGR+Dh1mpgqfWPJoC7EIcw7eTRc5/2ueDc8caCA7XKdcpAHNZYMdukIAL8o7FvxE8QV5/1uSLw==",
+ "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.81.0",
"version": "0.81.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
"resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
@@ -23,6 +23,7 @@
@@ -23,6 +23,7 @@
"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.236.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.236.0",
+ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.81.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.103.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.103.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -35,6 +35,7 @@ const { raw } = require('./render/html');
@@ -35,6 +35,7 @@ const { raw } = require('./render/html');
const accountDataLib = require('./identity/account-data');
const accountDataLib = require('./identity/account-data');
const { createStore } = require('./store');
const { createStore } = require('./store');
const { createSearchIndex } = require('./search');
const { createSearchIndex } = require('./search');
+const { createSearchRelay } = require('openvibe-publishing/search-relay');
const { createUpstreams } = require('./upstreams');
const { createUpstreams } = require('./upstreams');
const { createGuide } = require('./guide');
const { createGuide } = require('./guide');
const { createUpstreams: createGuideUpstreams } = require('./guide/upstream');
const { createUpstreams: createGuideUpstreams } = require('./guide/upstream');
@@ -61,7 +62,16 @@ async function createApp(opts = {}) {
@@ -61,7 +62,16 @@ async function createApp(opts = {}) {
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
const principal = createPrincipal({ config, keys });
const principal = createPrincipal({ config, keys });
- const search = createSearchIndex({ config, s });
+ // The queued Search documents (fun_outbox) go to OpenVibe.Search's owner API with this service's token; off
+ // without FUN_SEARCH_URL or the client secret. Started by server/index.js, never under FUN_DRILL.
+ const searchRelay = createSearchRelay({
+ db: s.db, table: 'fun_outbox', owner: 'fun', searchUrl: config.search.url, fetchImpl, now: s.now, log,
+ auth: config.search.url && config.oauth.clientSecret ? contracts.serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.search', scope: 'search.document.write', fetchImpl,
+ }) : null,
+ });
+ const search = createSearchIndex({ config, s, onQueued: () => searchRelay.kick() });
const store = createStore(s, { search, config });
const store = createStore(s, { search, config });
const upstreams = createUpstreams({ config, fetchImpl, now: s.now, log });
const upstreams = createUpstreams({ config, fetchImpl, now: s.now, log });
const guideUpstream = createGuideUpstreams({ config, s, fetchImpl, now: s.now, log });
const guideUpstream = createGuideUpstreams({ config, s, fetchImpl, now: s.now, log });
@@ -77,7 +87,7 @@ async function createApp(opts = {}) {
@@ -77,7 +87,7 @@ async function createApp(opts = {}) {
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Fun cannot answer account events'); });
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Fun cannot answer account events'); });
const ctx = {
const ctx = {
- config, s, keys, sso, principal, search, store, upstreams, guide, guideUpstream, coins, chatSync,
+ config, s, keys, sso, principal, search, searchRelay, store, upstreams, guide, guideUpstream, coins, chatSync,
accountData, accountSend, log,
accountData, accountSend, log,
};
};
@@ -62,6 +62,10 @@ function load(env = process.env) {
@@ -62,6 +62,10 @@ function load(env = process.env) {
scope: env.FUN_EVENTS_SCOPE || 'events.subscription.manage',
scope: env.FUN_EVENTS_SCOPE || 'events.subscription.manage',
},
},
+ // OpenVibe.Search's owner API: the documents queued in fun_outbox are delivered there (server/app.js,
+ // openvibe-publishing/search-relay) with this service's token (search.document.write). Unset: they wait.
+ search: { url: trim(env.FUN_SEARCH_URL || '') },
+
// The public APIs Fun reads: Live's channel status, Chat's public history (chat votes are read, never
// The public APIs Fun reads: Live's channel status, Chat's public history (chat votes are read, never
// posted), and Today's public event API (called with the viewer's own Network token, never a service one).
// posted), and Today's public event API (called with the viewer's own Network token, never a service one).
upstreams: {
upstreams: {
@@ -21,6 +21,7 @@ function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }
@@ -21,6 +21,7 @@ function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }
() => ctx.keys.client.stop(),
() => ctx.keys.client.stop(),
() => ctx.chatSync.stop(),
() => ctx.chatSync.stop(),
() => ctx.coins.stop(),
() => ctx.coins.stop(),
+ () => ctx.searchRelay.stop(),
async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); },
async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); },
() => ctx.s.close(),
() => ctx.s.close(),
...extra,
...extra,
@@ -48,6 +49,8 @@ async function start() {
@@ -48,6 +49,8 @@ async function start() {
// Jobs. Chat votes are read-only; both refuse to start under FUN_DRILL, where tests call runOnce/retryPending.
// Jobs. Chat votes are read-only; both refuse to start under FUN_DRILL, where tests call runOnce/retryPending.
ctx.chatSync.start();
ctx.chatSync.start();
ctx.coins.start();
ctx.coins.start();
+ // Queued Search documents → OpenVibe.Search (a tick a few seconds after boot, then every minute).
+ if (!config.drill) ctx.searchRelay.start();
// The two account topics at OpenVibe.Events: idempotent; off without the URL, the delivery secret and the client
// The two account topics at OpenVibe.Events: idempotent; off without the URL, the delivery secret and the client
// secret. The consumer itself is mounted in server/app.js.
// secret. The consumer itself is mounted in server/app.js.
@@ -4,8 +4,9 @@
@@ -4,8 +4,9 @@
* OpenVibe.Fun's Search Fabric feed. A public IRL quest is one `search.index-document@1` (owner `fun`, type `quest`,
* OpenVibe.Fun's Search Fabric feed. A public IRL quest is one `search.index-document@1` (owner `fun`, type `quest`,
* page `/q/<slug>`); a public group plan is another (type `plan`, page `/plans/<slug>`). They are queued in
* page `/q/<slug>`); a public group plan is another (type `plan`, page `/plans/<slug>`). They are queued in
* `fun_outbox` in the same transaction as the change; hiding or cancelling one queues a tombstone, which wins over
* `fun_outbox` in the same transaction as the change; hiding or cancelling one queues a tombstone, which wins over
- * any older revision. The relay is deliberately not part of v1: rows wait until OpenVibe.Search's document writer
- * is configured for `fun` (it needs the search.document.write grant).
+ * any older revision. openvibe-publishing/search-relay (server/app.js) delivers them to Search's owner API with
+ * this service's token (search.document.write), a few seconds after each change and every minute; without
+ * FUN_SEARCH_URL they wait.
*
*
* A quest/plan body is the public text the person wrote plus the public facts around it. No voter, subject, chat
* A quest/plan body is the public text the person wrote plus the public facts around it. No voter, subject, chat
* author or token is ever part of a document.
* author or token is ever part of a document.
@@ -107,7 +108,7 @@ function planDocument(p, options = [], { baseUrl = 'https://openvibe.fun' } = {}
@@ -107,7 +108,7 @@ function planDocument(p, options = [], { baseUrl = 'https://openvibe.fun' } = {}
};
};
}
}
-function createSearchIndex({ config, s, baseUrl = config.baseUrl }) {
+function createSearchIndex({ config, s, baseUrl = config.baseUrl, onQueued = null }) {
const nowIso = () => new Date(s.now()).toISOString();
const nowIso = () => new Date(s.now()).toISOString();
const base = String(baseUrl).replace(/\/+$/, '');
const base = String(baseUrl).replace(/\/+$/, '');
@@ -119,6 +120,7 @@ function createSearchIndex({ config, s, baseUrl = config.baseUrl }) {
@@ -119,6 +120,7 @@ function createSearchIndex({ config, s, baseUrl = config.baseUrl }) {
VALUES ($1, $2, $3::jsonb, $4::timestamptz, $4::timestamptz)`,
VALUES ($1, $2, $3::jsonb, $4::timestamptz, $4::timestamptz)`,
[eventId, topic, JSON.stringify(payload), nowIso()],
[eventId, topic, JSON.stringify(payload), nowIso()],
);
);
+ if (onQueued) onQueued();
return eventId;
return eventId;
}
}
@@ -398,7 +398,7 @@ ${ep('POST', '/api/v1/plans/:slug/options|vote|decide', 'Add an option, cast one
@@ -398,7 +398,7 @@ ${ep('POST', '/api/v1/plans/:slug/options|vote|decide', 'Add an option, cast one
${ep('POST', '/api/v1/plans/:slug/today', 'Adds the winner to the caller\'s own OpenVibe.Today with their token and an idempotency key.')}
${ep('POST', '/api/v1/plans/:slug/today', 'Adds the winner to the caller\'s own OpenVibe.Today with their token and an idempotency key.')}
${ep('POST', '/api/v1/plans/:slug/hide', 'Staff.')}
${ep('POST', '/api/v1/plans/:slug/hide', 'Staff.')}
</dl></section>
</dl></section>
-<section id="fu-search"><h2>Search</h2><p class="fu-fine">Each public quest is one <code>search.index-document@1</code> (owner <code>fun</code>, type <code>quest</code>, <code>/q/<slug></code>); each public plan is type <code>plan</code> (<code>/plans/<slug></code>). They wait in <code>fun_outbox</code>; hiding or cancelling queues a tombstone. The relay needs the <code>search.document.write</code> grant and is not in v1.</p></section>
+<section id="fu-search"><h2>Search</h2><p class="fu-fine">Each public quest is one <code>search.index-document@1</code> (owner <code>fun</code>, type <code>quest</code>, <code>/q/<slug></code>); each public plan is type <code>plan</code> (<code>/plans/<slug></code>). They are queued in <code>fun_outbox</code> and delivered to OpenVibe.Search within a minute; hiding or cancelling queues a tombstone.</p></section>
</div>`;
</div>`;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+'use strict';
+/**
+ * The queued Search documents reach OpenVibe.Search (server/app.js, openvibe-publishing/search-relay): with
+ * FUN_SEARCH_URL set, accepting a quest PUTs its document to Search's owner API with a service token for
+ * openvibe.search, and the row leaves the queue. Without the URL the relay is off and the rows wait.
+ */
+const assert = require('assert');
+const http = require('http');
+const { boot, check, done } = require('./helpers/boot');
+
+function fakeSearch() {
+ const calls = [];
+ const server = http.createServer((req, res) => {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ calls.push({ method: req.method, url: req.url, auth: req.headers.authorization || '', body: body ? JSON.parse(body) : null });
+ res.writeHead(200, { 'content-type': 'application/json' });
+ res.end(JSON.stringify({ outcome: 'applied' }));
+ });
+ });
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) })));
+}
+
+(async () => {
+ const search = await fakeSearch();
+ const t = await boot({ env: { FUN_SEARCH_URL: search.url } });
+ try {
+ const ada = t.network.addUser('ada');
+ const kim = t.network.addUser('kim');
+
+ await check('an accepted quest is PUT to Search with a service token, and the row leaves the queue', async () => {
+ assert.strictEqual(t.ctx.searchRelay.enabled, true);
+ const b = await t.get('/api/v1/quests/boards', { as: ada, json: { live_username: 'ada', title: 'Relay board' } });
+ const quest = (await t.get(`/api/v1/quests/boards/${b.json().id}/suggest`, { as: kim, json: { title: 'Find the oldest tree', brief: 'Walk to the park.' } })).json();
+ assert.strictEqual((await t.get(`/api/v1/quests/${quest.slug}/accept`, { as: ada, json: {} })).status, 200);
+ const out = await t.ctx.searchRelay.drain();
+ assert.ok(out.sent >= 1, JSON.stringify(out));
+ const put = search.calls.find((c) => c.method === 'PUT' && c.url === `/api/v1/documents/fun/quest/${quest.id}`);
+ assert.ok(put, JSON.stringify(search.calls.map((c) => `${c.method} ${c.url}`)));
+ assert.match(put.auth, /^Bearer \S+/);
+ assert.strictEqual(put.body.canonical_url, `https://openvibe.fun/q/${quest.slug}`);
+ assert.strictEqual((await t.ctx.searchRelay.status()).pending, 0);
+ });
+ } finally {
+ await t.close();
+ await search.close();
+ }
+
+ const off = await boot();
+ try {
+ await check('without FUN_SEARCH_URL the relay is off', async () => {
+ assert.strictEqual(off.ctx.searchRelay.enabled, false);
+ assert.strictEqual(await off.ctx.searchRelay.drain(), null);
+ });
+ } finally {
+ await off.close();
+ }
+ done();
+})();