openvibers/openvibe.meme Commits
Meme entries reach OpenVibe.Search (search-relay) (#65)
Changes
@@@ -34,6 +34,8 @@ OV_OAUTH_CLIENT_SECRET=MEME_EVENTS_SECRET=MEME_EVENTS_URL=#MEME_EVENTS_ENDPOINT=http://127.0.0.1:5120/internal/events++# OpenVibe.Search's owner API: the queued Search documents (meme_outbox) are delivered there. Unset: they wait.++#MEME_SEARCH_URL=http://127.0.0.1:4710#MEME_EVENTS_INTERVAL_MS=5000# The public APIs the importer and the citation re-verifier read. No service grant is used for them.@@@ -100,8 +100,9 @@ a chat line, a VOD second, a Rundown segment — a timeline of later uses, a staThe one service grant Meme holds is OpenVibe.AI's `ai.run.create`, used by the judge and the encyclopedia.- **Identity, roles and accounts** — OpenVibe.Network. Meme verifies session tokens against its JWKS; a staffroute asks for `global_mod` or above.--- **Search** — OpenVibe.Search. Meme produces `search.index-document@1` documents in its own outbox; the Search-- document writer is not wired yet because `meme` has no `search.document.write` grant.++- **Search** — OpenVibe.Search. Meme produces `search.index-document@1` documents in its own outbox, and++ `openvibe-publishing/search-relay` delivers them to Search's owner API with Meme's `search.document.write` grant++ (`MEME_SEARCH_URL`; unset, they wait).- **Events, the host and the database** — OpenVibe.Events, OpenVibe.Host.## Depends on@@@ -304,8 +305,8 @@ A transient 5xx is recorded and changes nothing: a hiccup must not erase a realverbatim quotes as plain text), facets `kind`/`status`/`source`, authorship `ai_assisted`, and one provenancereference per live citation. Publishing or editing queues `meme.index_document.upserted`; hiding, deleting orlosing the origin queues `meme.index_document.deleted` (a tombstone wins over an older revision). Rows live in--`meme_outbox` in the same transaction as the entry change and wait for the `search.document.write` grant; the relay--is deliberately not part of v1. The `search-feed` recipe (Work, Help, Rent, Quest) is followed: a document from one++`meme_outbox` in the same transaction as the entry change; `openvibe-publishing/search-relay` (v1.5.0) sends them to++Search's owner API a few seconds after the change and every minute, oldest first, superseding a document's older rows. The `search-feed` recipe (Work, Help, Rent, Quest) is followed: a document from onerow, a sequencer revision in the row, a tombstone when the page is no longer public.## Pages@@@ -40,13 +40,12 @@"Search Fabric documents (owner meme, type entry, /m/<slug>) with scope/labels facets and the origin as provenance, in meme_outbox, with a tombstone when an entry is hidden","The caption generator: a template, 1–4 lines × 120 characters, a blocked-word list, server-rendered HTML preview, text lines stored in jsonb","The API: public cursor-paged entries, an entry with citations and timeline, a zero-filled use series, trending, the week, templates, captions, reports, and staff publish/hide/patch",-- "Search Fabric documents (owner meme, type entry, /m/<slug>) in meme_outbox, with a tombstone when an entry is hidden",++ "Search Fabric documents (owner meme, type entry, /m/<slug>) in meme_outbox, with a tombstone when an entry is hidden, delivered to Search's owner API by openvibe-publishing/search-relay","Sign-in with OpenVibe.Network (PKCE S256, meme_at/meme_rt/meme_oauth), staff role global_mod+","Account export and deletion over votes, captions and reports","Server-rendered pages with stable mm- ids/classes, cache headers, discovery artifacts and deploy files"],"notYet": [-- "The Search document relay: documents wait in meme_outbox until Network grants meme search.document.write","A per-message Chat permalink API; a chat citation stores the message id and the public history is re-read to verify it","Automatic status transitions by the proposed rising/peaked/classic/dead rules; the importer sets a first status and staff can change it","Real template artwork: v1 ships three data-free local SVG placeholders; a real template is an openvibe.media object",@@@ -17,6 +17,7 @@"iovalkey": "^0.4.0","opentype.js": "^1.3.4","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.234.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",@@@ -1646,6 +1647,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",@@@ -28,10 +28,11 @@"iovalkey": "^0.4.0","opentype.js": "^1.3.4","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.234.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",-- "sharp": "^0.33.5"++ "sharp": "^0.33.5"},"engines": {"node": ">=22.0.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 { createImporter } = require('./importer');const { createReverifier } = require('./reverify');@@@ -75,7 +76,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 (meme_outbox) go to OpenVibe.Search's owner API with this service's token; off++ // without MEME_SEARCH_URL or the client secret. Started by server/index.js, never under MEME_DRILL.++ const searchRelay = createSearchRelay({++ db: s.db, table: 'meme_outbox', owner: 'meme', 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 entries = createStore(s, { search });const upstreams = createUpstreams({ config, fetchImpl, log });const importer = createImporter({ config, s, entries, upstreams, fetchImpl, log });@@@ -108,7 +118,7 @@ async function createApp(opts = {}) {: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Meme cannot answer account events'); });const ctx = {-- config, s, keys, sso, principal, search, entries, upstreams, importer, reverifier, accountData, accountSend, log,++ config, s, keys, sso, principal, search, searchRelay, entries, upstreams, importer, reverifier, accountData, accountSend, log,ai: aiRunner, judgeAi: ai, context, judge, judgeJob,media, trends, reddit, sourceRegistration,redditReader, analyzer,@@@ -61,6 +61,10 @@ function load(env = process.env) {scope: env.MEME_EVENTS_SCOPE || 'events.event.publish',},++ // OpenVibe.Search's owner API: the documents queued in meme_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.MEME_SEARCH_URL || '') },++// The public APIs the importer reads. No service grant is used in v1: these are the anonymous reads a// person can make too (OpenVibe.Live's Rundown and transcripts, OpenVibe.Chat's history and timeline).upstreams: {@@@ -23,6 +23,7 @@ function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }() => ctx.reverifier.stop(),() => ctx.judgeJob.stop(),() => ctx.reddit.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,@@@ -54,6 +55,8 @@ async function start() {ctx.reverifier.start();ctx.judgeJob.start();ctx.reddit.start();++ // Queued Search documents → OpenVibe.Search (a tick a few seconds after boot, then every minute).++ if (!config.drill) ctx.searchRelay.start();// The Reddit source is created disabled in Sources, once, when the owner asks (MEME_SOURCES_REGISTER=1).if (!config.drill) ctx.sourceRegistration.ensure().catch(() => {});@@@ -7,8 +7,9 @@* summary, the verbatim quotes as plain-text body, facets `kind`/`status`/`source`, authorship `ai_assisted` and* one provenance reference per citation — the same shape Work, Help and the other record products send.*-- * No service grant is needed to queue the rows: they wait in the outbox until OpenVibe.Search's document writer-- * is configured for `meme` (research §6.2). The relay is deliberately not part of v1.++ * No service grant is needed to queue the rows. 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 (onQueued)++ * and every minute; without MEME_SEARCH_URL they wait.*/const { ids } = require('openvibe-contracts');@@@ -73,7 +74,7 @@ function documentOf(entry, citations = [], { baseUrl = 'https://openvibe.meme',};}--function createSearchIndex({ config, s, baseUrl = config.baseUrl }) {++function createSearchIndex({ config, s, baseUrl = config.baseUrl, onQueued = null }) {const nowIso = () => new Date(s.now()).toISOString();async function enqueue(topic, payload, t = null) {@@@ -84,6 +85,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;}@@@ -254,7 +254,7 @@ ${ep('POST', '/api/v1/entries/:slug/hide', 'Remove it from public lists and tomb${ep('PATCH', '/api/v1/entries/:slug', '<code>{ title, kind, status }</code>: change an entry; status history is kept.')}${ep('GET', '/api/v1/reports', 'The open takedown/correction reports.')}</dl></section>--<section id="mm-search"><h2>Search</h2><p class="mm-fine">Each public entry is one <code>search.index-document@1</code> (owner <code>meme</code>, type <code>entry</code>, canonical page <code>/m/<slug></code>) queued in <code>meme_outbox</code>; hiding or deleting an entry queues a tombstone. The importer reads only public APIs and never publishes: a candidate stays hidden until a staff person publishes it.</p></section>++<section id="mm-search"><h2>Search</h2><p class="mm-fine">Each public entry is one <code>search.index-document@1</code> (owner <code>meme</code>, type <code>entry</code>, canonical page <code>/m/<slug></code>) queued in <code>meme_outbox</code> and delivered to OpenVibe.Search within a minute; hiding or deleting an entry queues a tombstone. The importer reads only public APIs and never publishes: a candidate stays hidden until a staff person publishes it.</p></section></div>`;}nnew file mode 100644@@@ -0,0 +1,79 @@++'use strict';++/**++ * The queued Search documents reach OpenVibe.Search (server/app.js, openvibe-publishing/search-relay): with++ * MEME_SEARCH_URL set, publishing an entry PUTs its document to Search's owner API with a service token for++ * openvibe.search, and hiding it DELETEs it at the next revision; a delivered 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 function makeEntry(t, slug) {++ return t.ctx.entries.createCandidate({++ slug, title: 'Robot is offline', kind: 'catchphrase', summary: 'The robot is offline, again.', status: 'rising',++ first_seen_at: '2026-10-20T10:00:00Z', last_seen_at: new Date().toISOString(), visibility: 'hidden',++ citations: [{ role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: '1001', chat_message_id: 1001, quote_text: 'the robot is offline again', url: `${t.upstreams.url}/`, observed_at: '2026-10-20T10:00:00Z' }],++ });++}++++(async () => {++ const search = await fakeSearch();++ const t = await boot({ env: { MEME_SEARCH_URL: search.url } });++ try {++ const mod = t.network.addUser('mod', { role: 'global_mod' });++ await makeEntry(t, 'relay-proof');++ const e = await t.ctx.s.db.maybe("SELECT id FROM meme_entries WHERE slug = 'relay-proof'");++++ await check('publishing an entry PUTs its document to Search with a service token, and the row leaves the queue', async () => {++ assert.strictEqual(t.ctx.searchRelay.enabled, true);++ assert.strictEqual((await t.get('/api/v1/entries/relay-proof/publish', { as: mod, method: 'POST' })).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');++ assert.ok(put, 'a PUT');++ assert.strictEqual(put.url, `/api/v1/documents/meme/entry/${e.id}`);++ assert.match(put.auth, /^Bearer \S+/);++ assert.strictEqual(put.body.canonical_url, 'https://openvibe.meme/m/relay-proof');++ assert.strictEqual(put.body.title, 'Robot is offline');++ assert.strictEqual((await t.ctx.searchRelay.status()).pending, 0);++ });++++ await check('hiding it DELETEs the document at the next revision', async () => {++ const before = search.calls.length;++ assert.strictEqual((await t.get('/api/v1/entries/relay-proof/hide', { as: mod, method: 'POST' })).status, 200);++ await t.ctx.searchRelay.drain();++ const del = search.calls.slice(before).find((c) => c.method === 'DELETE');++ assert.ok(del, 'a DELETE');++ assert.match(del.url, new RegExp(`^/api/v1/documents/meme/entry/${e.id}\\?revision=\\d+$`));++ });++ } finally {++ await t.close();++ await search.close();++ }++++ const off = await boot();++ try {++ await check('without MEME_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();++ }++})();
@@ -34,6 +34,8 @@ OV_OAUTH_CLIENT_SECRET=
@@ -34,6 +34,8 @@ OV_OAUTH_CLIENT_SECRET=
MEME_EVENTS_SECRET=
MEME_EVENTS_SECRET=
MEME_EVENTS_URL=
MEME_EVENTS_URL=
#MEME_EVENTS_ENDPOINT=http://127.0.0.1:5120/internal/events
#MEME_EVENTS_ENDPOINT=http://127.0.0.1:5120/internal/events
+# OpenVibe.Search's owner API: the queued Search documents (meme_outbox) are delivered there. Unset: they wait.
+#MEME_SEARCH_URL=http://127.0.0.1:4710
#MEME_EVENTS_INTERVAL_MS=5000
#MEME_EVENTS_INTERVAL_MS=5000
# The public APIs the importer and the citation re-verifier read. No service grant is used for them.
# The public APIs the importer and the citation re-verifier read. No service grant is used for them.
@@ -100,8 +100,9 @@ a chat line, a VOD second, a Rundown segment — a timeline of later uses, a sta
@@ -100,8 +100,9 @@ a chat line, a VOD second, a Rundown segment — a timeline of later uses, a sta
The one service grant Meme holds is OpenVibe.AI's `ai.run.create`, used by the judge and the encyclopedia.
The one service grant Meme holds is OpenVibe.AI's `ai.run.create`, used by the judge and the encyclopedia.
- **Identity, roles and accounts** — OpenVibe.Network. Meme verifies session tokens against its JWKS; a staff
- **Identity, roles and accounts** — OpenVibe.Network. Meme verifies session tokens against its JWKS; a staff
route asks for `global_mod` or above.
route asks for `global_mod` or above.
-- **Search** — OpenVibe.Search. Meme produces `search.index-document@1` documents in its own outbox; the Search
- document writer is not wired yet because `meme` has no `search.document.write` grant.
+- **Search** — OpenVibe.Search. Meme produces `search.index-document@1` documents in its own outbox, and
+ `openvibe-publishing/search-relay` delivers them to Search's owner API with Meme's `search.document.write` grant
+ (`MEME_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.
## Depends on
## Depends on
@@ -304,8 +305,8 @@ A transient 5xx is recorded and changes nothing: a hiccup must not erase a real
@@ -304,8 +305,8 @@ A transient 5xx is recorded and changes nothing: a hiccup must not erase a real
verbatim quotes as plain text), facets `kind`/`status`/`source`, authorship `ai_assisted`, and one provenance
verbatim quotes as plain text), facets `kind`/`status`/`source`, authorship `ai_assisted`, and one provenance
reference per live citation. Publishing or editing queues `meme.index_document.upserted`; hiding, deleting or
reference per live citation. Publishing or editing queues `meme.index_document.upserted`; hiding, deleting or
losing the origin queues `meme.index_document.deleted` (a tombstone wins over an older revision). Rows live in
losing the origin queues `meme.index_document.deleted` (a tombstone wins over an older revision). Rows live in
-`meme_outbox` in the same transaction as the entry change and wait for the `search.document.write` grant; the relay
-is deliberately not part of v1. The `search-feed` recipe (Work, Help, Rent, Quest) is followed: a document from one
+`meme_outbox` in the same transaction as the entry change; `openvibe-publishing/search-relay` (v1.5.0) sends them to
+Search's owner API a few seconds after the change and every minute, oldest first, superseding a document's older rows. The `search-feed` recipe (Work, Help, Rent, Quest) is followed: a document from one
row, a sequencer revision in the row, a tombstone when the page is no longer public.
row, a sequencer revision in the row, a tombstone when the page is no longer public.
## Pages
## Pages
@@ -40,13 +40,12 @@
@@ -40,13 +40,12 @@
"Search Fabric documents (owner meme, type entry, /m/<slug>) with scope/labels facets and the origin as provenance, in meme_outbox, with a tombstone when an entry is hidden",
"Search Fabric documents (owner meme, type entry, /m/<slug>) with scope/labels facets and the origin as provenance, in meme_outbox, with a tombstone when an entry is hidden",
"The caption generator: a template, 1–4 lines × 120 characters, a blocked-word list, server-rendered HTML preview, text lines stored in jsonb",
"The caption generator: a template, 1–4 lines × 120 characters, a blocked-word list, server-rendered HTML preview, text lines stored in jsonb",
"The API: public cursor-paged entries, an entry with citations and timeline, a zero-filled use series, trending, the week, templates, captions, reports, and staff publish/hide/patch",
"The API: public cursor-paged entries, an entry with citations and timeline, a zero-filled use series, trending, the week, templates, captions, reports, and staff publish/hide/patch",
- "Search Fabric documents (owner meme, type entry, /m/<slug>) in meme_outbox, with a tombstone when an entry is hidden",
+ "Search Fabric documents (owner meme, type entry, /m/<slug>) in meme_outbox, with a tombstone when an entry is hidden, delivered to Search's owner API by openvibe-publishing/search-relay",
"Sign-in with OpenVibe.Network (PKCE S256, meme_at/meme_rt/meme_oauth), staff role global_mod+",
"Sign-in with OpenVibe.Network (PKCE S256, meme_at/meme_rt/meme_oauth), staff role global_mod+",
"Account export and deletion over votes, captions and reports",
"Account export and deletion over votes, captions and reports",
"Server-rendered pages with stable mm- ids/classes, cache headers, discovery artifacts and deploy files"
"Server-rendered pages with stable mm- ids/classes, cache headers, discovery artifacts and deploy files"
],
],
"notYet": [
"notYet": [
- "The Search document relay: documents wait in meme_outbox until Network grants meme search.document.write",
"A per-message Chat permalink API; a chat citation stores the message id and the public history is re-read to verify it",
"A per-message Chat permalink API; a chat citation stores the message id and the public history is re-read to verify it",
"Automatic status transitions by the proposed rising/peaked/classic/dead rules; the importer sets a first status and staff can change it",
"Automatic status transitions by the proposed rising/peaked/classic/dead rules; the importer sets a first status and staff can change it",
"Real template artwork: v1 ships three data-free local SVG placeholders; a real template is an openvibe.media object",
"Real template artwork: v1 ships three data-free local SVG placeholders; a real template is an openvibe.media object",
@@ -17,6 +17,7 @@
@@ -17,6 +17,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"opentype.js": "^1.3.4",
"opentype.js": "^1.3.4",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.234.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.234.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",
@@ -1646,6 +1647,24 @@
@@ -1646,6 +1647,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",
@@ -28,10 +28,11 @@
@@ -28,10 +28,11 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"opentype.js": "^1.3.4",
"opentype.js": "^1.3.4",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.234.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.234.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",
- "sharp": "^0.33.5"
+ "sharp": "^0.33.5"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.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 { createImporter } = require('./importer');
const { createImporter } = require('./importer');
const { createReverifier } = require('./reverify');
const { createReverifier } = require('./reverify');
@@ -75,7 +76,16 @@ async function createApp(opts = {}) {
@@ -75,7 +76,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 (meme_outbox) go to OpenVibe.Search's owner API with this service's token; off
+ // without MEME_SEARCH_URL or the client secret. Started by server/index.js, never under MEME_DRILL.
+ const searchRelay = createSearchRelay({
+ db: s.db, table: 'meme_outbox', owner: 'meme', 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 entries = createStore(s, { search });
const entries = createStore(s, { search });
const upstreams = createUpstreams({ config, fetchImpl, log });
const upstreams = createUpstreams({ config, fetchImpl, log });
const importer = createImporter({ config, s, entries, upstreams, fetchImpl, log });
const importer = createImporter({ config, s, entries, upstreams, fetchImpl, log });
@@ -108,7 +118,7 @@ async function createApp(opts = {}) {
@@ -108,7 +118,7 @@ async function createApp(opts = {}) {
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Meme cannot answer account events'); });
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Meme cannot answer account events'); });
const ctx = {
const ctx = {
- config, s, keys, sso, principal, search, entries, upstreams, importer, reverifier, accountData, accountSend, log,
+ config, s, keys, sso, principal, search, searchRelay, entries, upstreams, importer, reverifier, accountData, accountSend, log,
ai: aiRunner, judgeAi: ai, context, judge, judgeJob,
ai: aiRunner, judgeAi: ai, context, judge, judgeJob,
media, trends, reddit, sourceRegistration,
media, trends, reddit, sourceRegistration,
redditReader, analyzer,
redditReader, analyzer,
@@ -61,6 +61,10 @@ function load(env = process.env) {
@@ -61,6 +61,10 @@ function load(env = process.env) {
scope: env.MEME_EVENTS_SCOPE || 'events.event.publish',
scope: env.MEME_EVENTS_SCOPE || 'events.event.publish',
},
},
+ // OpenVibe.Search's owner API: the documents queued in meme_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.MEME_SEARCH_URL || '') },
+
// The public APIs the importer reads. No service grant is used in v1: these are the anonymous reads a
// The public APIs the importer reads. No service grant is used in v1: these are the anonymous reads a
// person can make too (OpenVibe.Live's Rundown and transcripts, OpenVibe.Chat's history and timeline).
// person can make too (OpenVibe.Live's Rundown and transcripts, OpenVibe.Chat's history and timeline).
upstreams: {
upstreams: {
@@ -23,6 +23,7 @@ function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }
@@ -23,6 +23,7 @@ function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }
() => ctx.reverifier.stop(),
() => ctx.reverifier.stop(),
() => ctx.judgeJob.stop(),
() => ctx.judgeJob.stop(),
() => ctx.reddit.stop(),
() => ctx.reddit.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,
@@ -54,6 +55,8 @@ async function start() {
@@ -54,6 +55,8 @@ async function start() {
ctx.reverifier.start();
ctx.reverifier.start();
ctx.judgeJob.start();
ctx.judgeJob.start();
ctx.reddit.start();
ctx.reddit.start();
+ // Queued Search documents → OpenVibe.Search (a tick a few seconds after boot, then every minute).
+ if (!config.drill) ctx.searchRelay.start();
// The Reddit source is created disabled in Sources, once, when the owner asks (MEME_SOURCES_REGISTER=1).
// The Reddit source is created disabled in Sources, once, when the owner asks (MEME_SOURCES_REGISTER=1).
if (!config.drill) ctx.sourceRegistration.ensure().catch(() => {});
if (!config.drill) ctx.sourceRegistration.ensure().catch(() => {});
@@ -7,8 +7,9 @@
@@ -7,8 +7,9 @@
* summary, the verbatim quotes as plain-text body, facets `kind`/`status`/`source`, authorship `ai_assisted` and
* summary, the verbatim quotes as plain-text body, facets `kind`/`status`/`source`, authorship `ai_assisted` and
* one provenance reference per citation — the same shape Work, Help and the other record products send.
* one provenance reference per citation — the same shape Work, Help and the other record products send.
*
*
- * No service grant is needed to queue the rows: they wait in the outbox until OpenVibe.Search's document writer
- * is configured for `meme` (research §6.2). The relay is deliberately not part of v1.
+ * No service grant is needed to queue the rows. 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 (onQueued)
+ * and every minute; without MEME_SEARCH_URL they wait.
*/
*/
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
@@ -73,7 +74,7 @@ function documentOf(entry, citations = [], { baseUrl = 'https://openvibe.meme',
@@ -73,7 +74,7 @@ function documentOf(entry, citations = [], { baseUrl = 'https://openvibe.meme',
};
};
}
}
-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();
async function enqueue(topic, payload, t = null) {
async function enqueue(topic, payload, t = null) {
@@ -84,6 +85,7 @@ function createSearchIndex({ config, s, baseUrl = config.baseUrl }) {
@@ -84,6 +85,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;
}
}
@@ -254,7 +254,7 @@ ${ep('POST', '/api/v1/entries/:slug/hide', 'Remove it from public lists and tomb
@@ -254,7 +254,7 @@ ${ep('POST', '/api/v1/entries/:slug/hide', 'Remove it from public lists and tomb
${ep('PATCH', '/api/v1/entries/:slug', '<code>{ title, kind, status }</code>: change an entry; status history is kept.')}
${ep('PATCH', '/api/v1/entries/:slug', '<code>{ title, kind, status }</code>: change an entry; status history is kept.')}
${ep('GET', '/api/v1/reports', 'The open takedown/correction reports.')}
${ep('GET', '/api/v1/reports', 'The open takedown/correction reports.')}
</dl></section>
</dl></section>
-<section id="mm-search"><h2>Search</h2><p class="mm-fine">Each public entry is one <code>search.index-document@1</code> (owner <code>meme</code>, type <code>entry</code>, canonical page <code>/m/<slug></code>) queued in <code>meme_outbox</code>; hiding or deleting an entry queues a tombstone. The importer reads only public APIs and never publishes: a candidate stays hidden until a staff person publishes it.</p></section>
+<section id="mm-search"><h2>Search</h2><p class="mm-fine">Each public entry is one <code>search.index-document@1</code> (owner <code>meme</code>, type <code>entry</code>, canonical page <code>/m/<slug></code>) queued in <code>meme_outbox</code> and delivered to OpenVibe.Search within a minute; hiding or deleting an entry queues a tombstone. The importer reads only public APIs and never publishes: a candidate stays hidden until a staff person publishes it.</p></section>
</div>`;
</div>`;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
+'use strict';
+/**
+ * The queued Search documents reach OpenVibe.Search (server/app.js, openvibe-publishing/search-relay): with
+ * MEME_SEARCH_URL set, publishing an entry PUTs its document to Search's owner API with a service token for
+ * openvibe.search, and hiding it DELETEs it at the next revision; a delivered 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 function makeEntry(t, slug) {
+ return t.ctx.entries.createCandidate({
+ slug, title: 'Robot is offline', kind: 'catchphrase', summary: 'The robot is offline, again.', status: 'rising',
+ first_seen_at: '2026-10-20T10:00:00Z', last_seen_at: new Date().toISOString(), visibility: 'hidden',
+ citations: [{ role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: '1001', chat_message_id: 1001, quote_text: 'the robot is offline again', url: `${t.upstreams.url}/`, observed_at: '2026-10-20T10:00:00Z' }],
+ });
+}
+
+(async () => {
+ const search = await fakeSearch();
+ const t = await boot({ env: { MEME_SEARCH_URL: search.url } });
+ try {
+ const mod = t.network.addUser('mod', { role: 'global_mod' });
+ await makeEntry(t, 'relay-proof');
+ const e = await t.ctx.s.db.maybe("SELECT id FROM meme_entries WHERE slug = 'relay-proof'");
+
+ await check('publishing an entry PUTs its document to Search with a service token, and the row leaves the queue', async () => {
+ assert.strictEqual(t.ctx.searchRelay.enabled, true);
+ assert.strictEqual((await t.get('/api/v1/entries/relay-proof/publish', { as: mod, method: 'POST' })).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');
+ assert.ok(put, 'a PUT');
+ assert.strictEqual(put.url, `/api/v1/documents/meme/entry/${e.id}`);
+ assert.match(put.auth, /^Bearer \S+/);
+ assert.strictEqual(put.body.canonical_url, 'https://openvibe.meme/m/relay-proof');
+ assert.strictEqual(put.body.title, 'Robot is offline');
+ assert.strictEqual((await t.ctx.searchRelay.status()).pending, 0);
+ });
+
+ await check('hiding it DELETEs the document at the next revision', async () => {
+ const before = search.calls.length;
+ assert.strictEqual((await t.get('/api/v1/entries/relay-proof/hide', { as: mod, method: 'POST' })).status, 200);
+ await t.ctx.searchRelay.drain();
+ const del = search.calls.slice(before).find((c) => c.method === 'DELETE');
+ assert.ok(del, 'a DELETE');
+ assert.match(del.url, new RegExp(`^/api/v1/documents/meme/entry/${e.id}\\?revision=\\d+$`));
+ });
+ } finally {
+ await t.close();
+ await search.close();
+ }
+
+ const off = await boot();
+ try {
+ await check('without MEME_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();
+ }
+})();